Skip to main content

fraiseql_cli/
cli.rs

1//! CLI argument definitions: `Cli` struct, `Commands` enum, and all sub-command enums.
2
3use clap::{Parser, Subcommand};
4
5/// Exit codes documented in help text
6pub(crate) const EXIT_CODES_HELP: &str = "\
7EXIT CODES:
8    0  Success - Command completed successfully
9    1  Error - Command failed with an error
10    2  Validation failed - Schema or input validation failed";
11
12/// FraiseQL CLI - Compile GraphQL schemas to optimized SQL execution
13#[derive(Parser)]
14#[command(name = "fraiseql")]
15#[command(author, version, about, long_about = None)]
16#[command(propagate_version = true)]
17#[command(after_help = EXIT_CODES_HELP)]
18pub(crate) struct Cli {
19    /// Enable verbose logging
20    #[arg(short, long, global = true)]
21    pub(crate) verbose: bool,
22
23    /// Enable debug logging
24    #[arg(short, long, global = true)]
25    pub(crate) debug: bool,
26
27    /// Output as JSON (machine-readable)
28    #[arg(long, global = true)]
29    pub(crate) json: bool,
30
31    /// Suppress output (exit code only)
32    #[arg(short, long, global = true)]
33    pub(crate) quiet: bool,
34
35    #[command(subcommand)]
36    pub(crate) command: Commands,
37}
38
39#[derive(Subcommand)]
40pub(crate) enum Commands {
41    /// Compile schema to optimized schema.compiled.json
42    ///
43    /// Supports three workflows:
44    /// 1. TOML-only: fraiseql compile fraiseql.toml
45    /// 2. Language + TOML: fraiseql compile fraiseql.toml --types types.json
46    /// 3. Legacy JSON: fraiseql compile schema.json
47    #[command(after_help = "\
48EXAMPLES:
49    fraiseql compile fraiseql.toml
50    fraiseql compile fraiseql.toml --types types.json
51    fraiseql compile schema.json -o schema.compiled.json
52    fraiseql compile fraiseql.toml --check")]
53    Compile {
54        /// Input file path: fraiseql.toml (TOML) or schema.json (legacy)
55        #[arg(value_name = "INPUT")]
56        input: String,
57
58        /// Optional types.json from language implementation (used with fraiseql.toml)
59        #[arg(long, value_name = "TYPES")]
60        types: Option<String>,
61
62        /// Directory for auto-discovery of schema files (recursive *.json)
63        #[arg(long, value_name = "DIR")]
64        schema_dir: Option<String>,
65
66        /// Type files (repeatable): fraiseql compile fraiseql.toml --type-file a.json --type-file
67        /// b.json
68        #[arg(long = "type-file", value_name = "FILE")]
69        type_files: Vec<String>,
70
71        /// Query files (repeatable)
72        #[arg(long = "query-file", value_name = "FILE")]
73        query_files: Vec<String>,
74
75        /// Mutation files (repeatable)
76        #[arg(long = "mutation-file", value_name = "FILE")]
77        mutation_files: Vec<String>,
78
79        /// Output schema.compiled.json file path
80        #[arg(
81            short,
82            long,
83            value_name = "OUTPUT",
84            default_value = "schema.compiled.json"
85        )]
86        output: String,
87
88        /// Validate only, don't write output
89        #[arg(long)]
90        check: bool,
91
92        /// Skip embedding content hash in compiled schema (for test fixtures)
93        #[arg(long)]
94        skip_hash: bool,
95
96        /// Optional database URL: check the schema against the live database
97        ///
98        /// Validates each `sql_source`, mutation function, JSONB key and query argument
99        /// against the relations it names, and fills `native_columns` from the views'
100        /// columns. Drift classified as an error fails the compile (see `--allow-drift`).
101        #[arg(long, value_name = "DATABASE_URL")]
102        database: Option<String>,
103
104        /// Emit DDL files for all schema types to the given directory
105        ///
106        /// Writes `CREATE TABLE` DDL for each compiled type to `<DIR>/<type>.sql`.
107        /// Output is compatible with confiture's `db/schema/` directory format,
108        /// enabling `fraiseql migrate generate` to auto-detect schema drift.
109        #[arg(long, value_name = "DIR")]
110        emit_ddl: Option<String>,
111
112        /// Check compiled schema against the database for migration drift
113        ///
114        /// Emits DDL to a temporary directory, then delegates to
115        /// `confiture migrate validate` for drift detection.
116        /// Exits non-zero when the compiled schema diverges from the database.
117        #[arg(long)]
118        check_migrations: bool,
119
120        /// Compile despite error-severity schema↔database drift (advisory mode)
121        ///
122        /// With `--database`, drift the linter classifies as an error (a
123        /// `sql_source` that does not exist, a required field's JSONB key
124        /// absent from every sampled row, a mutation with no backing function)
125        /// fails the compile. This flag downgrades those findings to reported
126        /// advisories and writes the artifact anyway.
127        #[arg(long, requires = "database")]
128        allow_drift: bool,
129
130        /// Project config to compile with. Default: the nearest fraiseql.toml beside
131        /// or above the input, up to the repository root — never the working directory
132        #[arg(long, value_name = "PATH")]
133        config: Option<String>,
134    },
135
136    /// Extract schema from annotated source files
137    ///
138    /// Parses FraiseQL annotations in any supported language and generates schema.json.
139    /// No language runtime required — pure text processing.
140    #[command(after_help = "\
141EXAMPLES:
142    fraiseql extract schema/schema.py
143    fraiseql extract schema/ --recursive
144    fraiseql extract schema.rs --language rust -o schema.json")]
145    Extract {
146        /// Source file(s) or directory to extract from
147        #[arg(value_name = "INPUT")]
148        input: Vec<String>,
149
150        /// Override language detection (python, typescript, rust, java, kotlin, go, csharp, swift,
151        /// scala)
152        #[arg(short, long)]
153        language: Option<String>,
154
155        /// Recursively scan directories
156        #[arg(short, long)]
157        recursive: bool,
158
159        /// Output file path
160        #[arg(short, long, default_value = "schema.json")]
161        output: String,
162    },
163
164    /// Explain query execution plan and complexity
165    ///
166    /// Shows GraphQL query execution plan, SQL, and complexity analysis.
167    #[command(after_help = "\
168EXAMPLES:
169    fraiseql explain '{ users { id name } }'
170    fraiseql explain '{ user(id: 1) { posts { title } } }' --json")]
171    Explain {
172        /// GraphQL query string
173        #[arg(value_name = "QUERY")]
174        query: String,
175    },
176
177    /// Calculate query complexity score
178    ///
179    /// Quick analysis of query complexity (depth, field count, score).
180    #[command(after_help = "\
181EXAMPLES:
182    fraiseql cost '{ users { id name } }'
183    fraiseql cost '{ deeply { nested { query { here } } } }' --json")]
184    Cost {
185        /// GraphQL query string
186        #[arg(value_name = "QUERY")]
187        query: String,
188    },
189
190    /// Analyze schema for optimization opportunities
191    ///
192    /// Provides recommendations across 6 categories:
193    /// performance, security, federation, complexity, caching, indexing
194    #[command(after_help = "\
195EXAMPLES:
196    fraiseql analyze schema.compiled.json
197    fraiseql analyze schema.compiled.json --json")]
198    Analyze {
199        /// Path to schema.compiled.json
200        #[arg(value_name = "SCHEMA")]
201        schema: String,
202    },
203
204    /// Analyze schema type dependencies
205    ///
206    /// Exports dependency graph, detects cycles, and finds unused types.
207    /// Supports multiple output formats for visualization and CI integration.
208    #[command(after_help = "\
209EXAMPLES:
210    fraiseql dependency-graph schema.compiled.json
211    fraiseql dependency-graph schema.compiled.json -f dot > graph.dot
212    fraiseql dependency-graph schema.compiled.json -f mermaid
213    fraiseql dependency-graph schema.compiled.json --json")]
214    DependencyGraph {
215        /// Path to schema.compiled.json
216        #[arg(value_name = "SCHEMA")]
217        schema: String,
218
219        /// Output format (json, dot, mermaid, d2, console)
220        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
221        format: String,
222    },
223
224    /// Federation tools: draw the graph subgraphs form, check a subgraph
225    ///
226    /// `graph` takes each subgraph's compiled schema and draws who extends whose entities.
227    #[command(after_help = "\
228EXAMPLES:
229    fraiseql federation graph users.compiled.json orders.compiled.json
230    fraiseql federation graph users.compiled.json orders.compiled.json -f dot
231    fraiseql federation graph users.compiled.json orders.compiled.json -f mermaid
232    fraiseql federation check schema.compiled.json --against other.compiled.json
233    fraiseql federation sdl schema.compiled.json -o subgraph.graphql   (--features federation)")]
234    Federation {
235        /// Schema path (positional argument passed to subcommand)
236        #[command(subcommand)]
237        command: FederationCommands,
238    },
239
240    /// Lint schema for FraiseQL design quality
241    ///
242    /// Analyzes schema using FraiseQL-calibrated design rules.
243    /// Detects JSONB batching issues, compilation problems, auth boundaries, etc.
244    #[command(after_help = "\
245EXAMPLES:
246    fraiseql lint schema.json
247    fraiseql lint schema.compiled.json --federation
248    fraiseql lint schema.json --fail-on-critical
249    fraiseql lint schema.json --json")]
250    Lint {
251        /// Path to schema.json or schema.compiled.json
252        #[arg(value_name = "SCHEMA")]
253        schema: String,
254
255        /// Only show federation audit
256        #[arg(long)]
257        federation: bool,
258
259        /// Only show cost audit
260        #[arg(long)]
261        cost: bool,
262
263        /// Only show cache audit
264        #[arg(long)]
265        cache: bool,
266
267        /// Only show auth audit
268        #[arg(long)]
269        auth: bool,
270
271        /// Only show compilation audit
272        #[arg(long)]
273        compilation: bool,
274
275        /// Exit with error if any critical issues found
276        #[arg(long)]
277        fail_on_critical: bool,
278
279        /// Exit with error if any warning or critical issues found
280        #[arg(long)]
281        fail_on_warning: bool,
282    },
283
284    /// Generate DDL for Arrow views (va_*, tv_*, ta_*)
285    #[command(after_help = "\
286EXAMPLES:
287    fraiseql generate-views -s schema.json -e User --view va_users
288    fraiseql generate-views -s schema.json -e Order --view tv_orders --refresh-strategy scheduled")]
289    GenerateViews {
290        /// Path to schema.json
291        #[arg(short, long, value_name = "SCHEMA")]
292        schema: String,
293
294        /// Entity name from schema
295        #[arg(short, long, value_name = "NAME")]
296        entity: String,
297
298        /// View name (must start with va_, tv_, or ta_)
299        #[arg(long, value_name = "NAME")]
300        view: String,
301
302        /// Refresh strategy (trigger-based or scheduled)
303        #[arg(long, value_name = "STRATEGY", default_value = "trigger-based")]
304        refresh_strategy: String,
305
306        /// Output file path (default: {view}.sql)
307        #[arg(short, long, value_name = "PATH")]
308        output: Option<String>,
309
310        /// Include helper/composition views
311        #[arg(long, default_value = "true")]
312        include_composition_views: bool,
313
314        /// Include monitoring functions
315        #[arg(long, default_value = "true")]
316        include_monitoring: bool,
317
318        /// Validate the generated DDL by executing it against PostgreSQL
319        /// (DATABASE_URL) in a rolled-back transaction; don't write a file
320        #[arg(long)]
321        validate: bool,
322
323        /// Show generation steps (use global --verbose flag)
324        #[arg(long, action = clap::ArgAction::SetTrue)]
325        gen_verbose: bool,
326    },
327
328    /// Generate external-write capture-trigger DDL for @subscribable types (#366)
329    ///
330    /// Emits a self-contained, idempotent PostgreSQL script (the capture function
331    /// plus per-table statement-level triggers) that brings raw external writes
332    /// (psql / migrations / third-party tools) to a `@subscribable` type's table(s)
333    /// onto the Change Spine, without double-emitting for FraiseQL-mediated writes.
334    #[command(after_help = "\
335EXAMPLES:
336    fraiseql generate-capture-triggers -s schema.compiled.json | psql \"$DATABASE_URL\"
337    fraiseql generate-capture-triggers -s schema.compiled.json -o capture.sql")]
338    GenerateCaptureTriggers {
339        /// Path to the compiled schema (schema.compiled.json)
340        #[arg(short, long, value_name = "SCHEMA")]
341        schema: String,
342
343        /// Output file path (default: stdout, for piping into psql)
344        #[arg(short, long, value_name = "PATH")]
345        output: Option<String>,
346
347        /// Omit the capture function definition (emit only the per-table triggers)
348        #[arg(long)]
349        no_function: bool,
350    },
351
352    /// Validate a compiled schema (schema.compiled.json) or fact tables
353    ///
354    /// Checks the schema `fraiseql compile` wrote, including:
355    /// - JSON structure validation
356    /// - Type reference validation
357    /// - Circular dependency detection (with --check-cycles)
358    /// - Unused type detection (with --check-unused)
359    ///
360    /// An authoring schema.json is checked by `fraiseql compile schema.json --check`.
361    #[command(after_help = "\
362EXAMPLES:
363    fraiseql validate schema.compiled.json
364    fraiseql validate schema.compiled.json --check-unused
365    fraiseql validate schema.compiled.json --strict
366    fraiseql validate facts -s schema.json --database postgres://localhost/db")]
367    Validate {
368        #[command(subcommand)]
369        command: Option<ValidateCommands>,
370
371        /// Compiled schema (schema.compiled.json) to validate (if no subcommand)
372        #[arg(value_name = "INPUT")]
373        input: Option<String>,
374
375        /// Check for circular dependencies between types
376        #[arg(long, default_value = "true")]
377        check_cycles: bool,
378
379        /// Check for unused types (no incoming references)
380        #[arg(long)]
381        check_unused: bool,
382
383        /// Strict mode: treat warnings as errors (unused types become errors)
384        #[arg(long)]
385        strict: bool,
386
387        /// Only analyze specific type(s) - comma-separated list
388        #[arg(long, value_name = "TYPES", value_delimiter = ',')]
389        types: Vec<String>,
390
391        /// Validate the mutation contract against a live PostgreSQL database.
392        ///
393        /// For each DB-backed mutation, checks that its `sql_source` function
394        /// exists with the argument layout the server sends and returns the
395        /// columns the server decodes into `MutationResponse`. Requires a
396        /// compiled schema (schema.compiled.json) as INPUT. (#397)
397        #[arg(long, value_name = "DATABASE_URL")]
398        against_db: Option<String>,
399    },
400
401    /// Introspect database for fact tables and output suggestions
402    #[command(after_help = "\
403EXAMPLES:
404    fraiseql introspect facts --database postgres://localhost/db
405    fraiseql introspect facts --database postgres://localhost/db -f json")]
406    Introspect {
407        #[command(subcommand)]
408        command: IntrospectCommands,
409    },
410
411    /// Generate authoring-language source from schema.json
412    ///
413    /// The inverse of `fraiseql extract`: reads a schema.json and produces annotated
414    /// source code in any of the 9 supported authoring languages.
415    #[command(after_help = "\
416EXAMPLES:
417    fraiseql generate schema.json --language python
418    fraiseql generate schema.json --language rust -o schema.rs
419    fraiseql generate schema.json --language typescript")]
420    Generate {
421        /// Path to schema.json
422        #[arg(value_name = "INPUT")]
423        input: String,
424
425        /// Target language (python, typescript, rust, java, kotlin, go, csharp, swift, scala)
426        #[arg(short, long)]
427        language: String,
428
429        /// Output file path (default: schema.<ext> based on language)
430        #[arg(short, long)]
431        output: Option<String>,
432    },
433
434    /// Generate consumer-side typed clients from a compiled schema
435    ///
436    /// Distinct from `generate`: `generate` emits server-side authoring code (write
437    /// FraiseQL types in another language, fed back into the compiler), whereas
438    /// `generate-client` emits a typed client for callers of a FraiseQL API
439    /// (interfaces + query/mutation functions) from a compiled schema.compiled.json.
440    #[command(after_help = "\
441EXAMPLES:
442    fraiseql generate-client typescript --out ./src/generated
443    fraiseql generate-client typescript --schema ./schema.compiled.json --out ./gen --force
444    fraiseql generate-client python --out ./app/fraiseql_client
445    fraiseql generate-client go --out ./internal/fraiseqlclient
446    fraiseql generate-client rust --out ./src/generated")]
447    GenerateClient {
448        #[command(subcommand)]
449        language: GenerateClientCommands,
450    },
451
452    /// Initialize a new FraiseQL project
453    ///
454    /// Creates project directory with fraiseql.toml, schema.json,
455    /// database DDL structure, and authoring skeleton.
456    #[command(after_help = "\
457EXAMPLES:
458    fraiseql init my-app
459    fraiseql init my-app --language typescript --database postgres
460    fraiseql init my-app --size xs --no-git")]
461    Init {
462        /// Project name (used as directory name)
463        #[arg(value_name = "PROJECT_NAME")]
464        project_name: String,
465
466        /// Authoring language (python, typescript, rust, java, kotlin, go, csharp, swift, scala)
467        #[arg(short, long, default_value = "python")]
468        language: String,
469
470        /// Target database (postgres — FraiseQL is PostgreSQL-only since v2.15.0)
471        #[arg(long, default_value = "postgres")]
472        database: String,
473
474        /// Project size: xs (single file), s (flat dirs), m (per-entity dirs)
475        #[arg(long, default_value = "s")]
476        size: String,
477
478        /// Skip git init
479        #[arg(long)]
480        no_git: bool,
481    },
482
483    /// Run database migrations
484    ///
485    /// The long help is `commands::migrate::MIGRATE_LONG_ABOUT`: it states the order the
486    /// database URL is resolved in, so it lives beside the resolver.
487    #[command(long_about = crate::commands::migrate::MIGRATE_LONG_ABOUT)]
488    #[command(after_help = "\
489EXAMPLES:
490    fraiseql migrate up --database postgres://localhost/mydb
491    fraiseql migrate down --steps 1
492    fraiseql migrate status
493    fraiseql migrate create add_posts_table")]
494    Migrate {
495        #[command(subcommand)]
496        command: MigrateCommands,
497    },
498
499    /// Generate Software Bill of Materials
500    ///
501    /// Parses Cargo.lock and fraiseql.toml to produce a compliance-ready SBOM.
502    #[command(after_help = "\
503EXAMPLES:
504    fraiseql sbom
505    fraiseql sbom --format spdx
506    fraiseql sbom --format cyclonedx --output sbom.json")]
507    Sbom {
508        /// Output format (cyclonedx, spdx)
509        #[arg(short, long, default_value = "cyclonedx")]
510        format: String,
511
512        /// Output file path (default: stdout)
513        #[arg(short, long, value_name = "FILE")]
514        output: Option<String>,
515    },
516
517    /// List every optional capability and whether this build has it
518    ///
519    /// Reports the server this binary links (the one `fraiseql run` starts): each
520    /// capability's maturity, whether it is compiled in, its Cargo feature, its config
521    /// keys and compiled-schema sections, the smallest published artifact that ships it,
522    /// and its documentation page.
523    #[cfg(feature = "run-server")]
524    #[command(after_help = "\
525EXAMPLES:
526    fraiseql capabilities
527    fraiseql capabilities --json")]
528    Capabilities,
529
530    /// First-party auth operations on the server's database
531    #[cfg(feature = "run-server")]
532    Auth {
533        #[command(subcommand)]
534        command: AuthCommands,
535    },
536
537    /// Compile schema and immediately start the GraphQL server
538    ///
539    /// Compiles the schema in-memory (no disk artifact) and starts the HTTP server.
540    /// With --watch, the server hot-reloads whenever the schema file changes.
541    ///
542    /// Server and database settings can be declared in fraiseql.toml under [server]
543    /// and [database] sections.  CLI flags take precedence over TOML settings, which
544    /// take precedence over defaults.  The database URL is resolved in this order:
545    /// --database flag > DATABASE_URL env var > [database].url in fraiseql.toml.
546    #[cfg(feature = "run-server")]
547    #[command(after_help = "\
548EXAMPLES:
549    fraiseql run
550    fraiseql run fraiseql.toml --database postgres://localhost/mydb
551    fraiseql run --port 3000 --watch
552    fraiseql run schema.json --introspection
553
554TOML CONFIG:
555    [server]
556    host = \"127.0.0.1\"
557    port = 9000
558
559    [server.cors]
560    origins = [\"https://app.example.com\"]
561
562    [database]
563    url      = \"${DATABASE_URL}\"
564    pool_min = 2
565    pool_max = 20")]
566    Run {
567        /// Input file path (fraiseql.toml or schema.json); auto-detected if omitted
568        #[arg(value_name = "INPUT")]
569        input: Option<String>,
570
571        /// Database URL (overrides [database].url in fraiseql.toml and DATABASE_URL env var)
572        // Long-only: the global `--debug` (`Cli.debug`) already owns `-d`. A local
573        // `-d`/`--database` short here collides with it — clap's debug_asserts panic in
574        // debug builds and the short is ambiguous in release builds (#650).
575        #[arg(long, value_name = "DATABASE_URL")]
576        database: Option<String>,
577
578        /// Port to listen on (overrides [server].port in fraiseql.toml)
579        #[arg(short, long, value_name = "PORT")]
580        port: Option<u16>,
581
582        /// Bind address (overrides [server].host in fraiseql.toml)
583        #[arg(long, value_name = "HOST")]
584        bind: Option<String>,
585
586        /// Watch input file for changes and hot-reload the server
587        #[arg(short, long)]
588        watch: bool,
589
590        /// Enable the GraphQL introspection endpoint (no auth required)
591        #[arg(long)]
592        introspection: bool,
593
594        /// Development only (FRAISEQL_ENV=development): connect with this URL first and
595        /// create the server's roles. Also read from FRAISEQL_ADMIN_DATABASE_URL.
596        ///
597        /// The engine's role is the user of the database URL, and first-party auth's role the
598        /// user of the URL in the variable `[auth.database] url_env` names. A role that does
599        /// not exist is created LOGIN NOSUPERUSER with its URL's password. A role that exists
600        /// gets only its password set from its URL: a re-run never changes its other
601        /// attributes. Each role is granted CONNECT and CREATE on the database, and the
602        /// engine's role CREATE on schema public. A URL whose user is the admin user is left
603        /// alone: the admin user is never changed. The URL must name the same database as the
604        /// server's.
605        #[arg(long, value_name = "ADMIN_DATABASE_URL")]
606        admin_database_url: Option<String>,
607    },
608
609    /// Watch a schema source and recompile + live-reload a running server on change
610    ///
611    /// Recompiles `schema.compiled.json` on every save and, with `--reload-url`,
612    /// POSTs it to a running server's `/api/v1/admin/reload-schema` endpoint for a
613    /// zero-downtime `ArcSwap` reload (no restart). Unlike `run --watch`, this drives
614    /// a server you started separately.
615    Watch {
616        /// Schema source to watch (fraiseql.toml or schema.json)
617        #[arg(value_name = "INPUT")]
618        input: String,
619
620        /// Compiled-schema output path (the server reads this on reload)
621        #[arg(short, long, default_value = "schema.compiled.json")]
622        output: String,
623
624        /// Base URL of a running server to live-reload (e.g. http://localhost:8080).
625        /// Omit to recompile to disk only.
626        #[arg(long, value_name = "URL")]
627        reload_url: Option<String>,
628
629        /// Bearer token for the server's admin reload endpoint
630        #[arg(long, value_name = "TOKEN")]
631        admin_token: Option<String>,
632
633        /// Database URL for compile-time validation (overrides DATABASE_URL)
634        #[arg(long, value_name = "DATABASE_URL")]
635        database: Option<String>,
636    },
637
638    /// Validate a trusted documents manifest
639    ///
640    /// Checks that the manifest JSON is well-formed and that each key
641    /// is a valid SHA-256 hex string matching its query body.
642    #[command(after_help = "\
643EXAMPLES:
644    fraiseql validate-documents manifest.json
645    fraiseql validate-documents manifest.json --max-cost 1000 --schema schema.compiled.json")]
646    ValidateDocuments {
647        /// Path to the trusted documents manifest JSON file
648        #[arg(value_name = "MANIFEST")]
649        manifest: String,
650
651        /// Fail validation when any document's estimated operation cost exceeds
652        /// this ceiling (#379). Costs are worst-case: a variable-valued
653        /// pagination argument is scored at its fail-closed ceiling.
654        #[arg(long, value_name = "COST")]
655        max_cost: Option<u64>,
656
657        /// Compiled schema (`schema.compiled.json`) supplying the
658        /// `[fraiseql.cost_weights]` used to score documents — the same numbers
659        /// the runtime enforces. Without it, cost equals the complexity score.
660        #[arg(long, value_name = "SCHEMA")]
661        schema: Option<String>,
662    },
663
664    /// Install FraiseQL mutation helper functions
665    ///
666    /// Installs SQL helper functions (fraiseql.mutation_ok, fraiseql.mutation_err, etc.)
667    /// to reduce boilerplate when writing mutation functions under the v2.4.0 protocol.
668    /// The helpers are installed in the `fraiseql` schema, which is owned by FraiseQL's
669    /// database role.
670    #[command(after_help = "\
671EXAMPLES:
672    fraiseql setup --database postgres://localhost/mydb
673    fraiseql setup --dry-run
674    fraiseql setup  # Uses [database].url from fraiseql.toml, else DATABASE_URL")]
675    Setup {
676        /// Database connection URL (else [database].url in fraiseql.toml, else the
677        /// DATABASE_URL env var)
678        #[arg(long, value_name = "DATABASE_URL")]
679        database: Option<String>,
680
681        /// Print SQL without applying changes
682        #[arg(long)]
683        dry_run: bool,
684    },
685
686    /// Inspect schema metadata from a running FraiseQL server
687    ///
688    /// Fetches field-level security metadata (encryption, scope requirements, deny actions)
689    /// from the server's `/api/v1/schema/metadata` endpoint and displays it as a table.
690    #[command(after_help = "\
691EXAMPLES:
692    fraiseql schema metadata
693    fraiseql schema metadata --server http://localhost:8080
694    fraiseql schema metadata --server https://api.example.com --token mytoken")]
695    Schema {
696        #[command(subcommand)]
697        command: SchemaCommands,
698    },
699
700    /// Execute one GraphQL operation against a database and print the JSON result
701    ///
702    /// Boots the compiled schema in-process (no long-lived server, no HTTP layer)
703    /// against PostgreSQL, runs a single query or mutation, prints the GraphQL JSON
704    /// response to stdout, and exits. Exits non-zero on a resolution error — a
705    /// scriptable "does this actually resolve?" check for CI and the inner loop.
706    ///
707    /// Mutations COMMIT unless `--dry-run` is given, which runs the mutation inside
708    /// a transaction that is rolled back (validate-bind-without-commit).
709    #[command(after_help = "\
710EXAMPLES:
711    fraiseql query '{ orders(limit: 1) { id } }'
712    fraiseql query --schema schema.compiled.json --database postgres://localhost/db '{ users { id } }'
713    fraiseql query --variables '{\"id\": 42}' 'query($id: Int!) { user(id: $id) { id } }'
714    fraiseql query --dry-run 'mutation { createUser(name: \"x\") { id } }'")]
715    Query {
716        /// GraphQL operation to execute (a single query or mutation document).
717        #[arg(value_name = "QUERY")]
718        query: String,
719
720        /// Path to schema.compiled.json.
721        #[arg(short = 's', long, default_value = "schema.compiled.json")]
722        schema: std::path::PathBuf,
723
724        /// Database URL (PostgreSQL only). Falls back to the DATABASE_URL env var.
725        #[arg(long, value_name = "DATABASE_URL")]
726        database: Option<String>,
727
728        /// GraphQL variables as a JSON object string (e.g. '{"id": 42}').
729        #[arg(long, value_name = "JSON")]
730        variables: Option<String>,
731
732        /// Run mutations inside a rolled-back transaction: the function binds and
733        /// executes (constraints, triggers, and response shape are validated) but
734        /// nothing is committed. No effect on queries. PostgreSQL only.
735        #[arg(long)]
736        dry_run: bool,
737    },
738
739    /// Run diagnostic checks for common FraiseQL setup problems
740    ///
741    /// Checks schema file, TOML config, DATABASE_URL, JWT secret, Redis, TLS,
742    /// and cache/auth coherence. Prints a color-coded report and exits 0 if
743    /// all checks pass or 1 if any check fails (warnings do not fail).
744    #[command(after_help = "\
745EXAMPLES:
746    fraiseql doctor
747    fraiseql doctor --schema schema.compiled.json --config fraiseql.toml
748    fraiseql doctor --db-url postgres://user:pass@host:5432/db
749    fraiseql doctor --runtime --against-db postgres://user:pass@host:5432/db
750    fraiseql doctor --json")]
751    Doctor {
752        /// Path to fraiseql.toml configuration file.
753        #[arg(long, default_value = "fraiseql.toml")]
754        config: std::path::PathBuf,
755
756        /// Path to schema.compiled.json.
757        #[arg(long, default_value = "schema.compiled.json")]
758        schema: std::path::PathBuf,
759
760        /// Override DATABASE_URL for the connectivity check.
761        #[arg(long)]
762        db_url: Option<String>,
763
764        /// Run the live-database passes against this PostgreSQL database: the
765        /// change-log contract drift check (compares core.tb_entity_change_log
766        /// against the shipped contract — #380), the PL/pgSQL body-resolution
767        /// pass (reports internal calls that no longer resolve — #409), the
768        /// mutation→function contract drift check (every declared mutation has a
769        /// matching backing function with a mutation_response-shaped return —
770        /// #384), and the view-drift check (the same L1/L2/L3 view-composition
771        /// linter `compile --database` runs, reported as structured checks —
772        /// #384).
773        ///
774        /// The body-resolution pass requires the `plpgsql_check` extension and
775        /// skips with a hint when it is unavailable.
776        #[arg(long, value_name = "DATABASE_URL")]
777        against_db: Option<String>,
778
779        /// Runtime smoke mode (#501): boot the compiled schema in-process and
780        /// probe each root query field with a minimal selection, dry-running each
781        /// no-argument mutation (rolled back). Confirms operations actually
782        /// *resolve* end-to-end, complementing the static `--against-db` drift
783        /// checks. Requires a PostgreSQL URL via `--against-db` (or `--db-url`).
784        /// Queries / mutations that require arguments are skipped with a warning.
785        #[arg(long)]
786        runtime: bool,
787
788        /// Schemas to scan in the body-resolution pass (comma-separated).
789        #[arg(
790            long,
791            value_name = "SCHEMAS",
792            value_delimiter = ',',
793            default_value = "public"
794        )]
795        schemas: Vec<String>,
796
797        /// Output machine-readable JSON (for CI integration).
798        #[arg(long)]
799        json: bool,
800    },
801
802    /// Change-log performance observability (#392)
803    ///
804    /// Reads the framework-owned change-log (`core.v_entity_change_log`) and turns
805    /// it into operator-facing forensics: latency regressions between two time
806    /// windows, and ad-hoc reads of the slowest mutations / duration completeness
807    /// / per-operation percentiles.
808    Perf {
809        #[command(subcommand)]
810        command: PerfCommands,
811    },
812
813    /// List scheduled ingress sources and their durable cursor state (#573)
814    ///
815    /// Reads the compiled schema for each source's schedule and `run_as` ceiling,
816    /// and — when a database is reachable — its cursor value, version, and staleness
817    /// (seconds since the last advance). Leadership and last-fire are not reported
818    /// here (an advisory lock has no visible holder, and the lease is released
819    /// between ticks): watch `fraiseql_source_*` metrics and the poller logs for
820    /// those. Always exits 0 — it is a report.
821    #[command(after_help = "\
822EXAMPLES:
823    fraiseql sources
824    fraiseql sources --schema schema.compiled.json
825    fraiseql sources --db-url postgres://user:pass@host:5432/db
826    fraiseql sources --json")]
827    Sources {
828        /// Path to schema.compiled.json.
829        #[arg(long, default_value = "schema.compiled.json")]
830        schema: std::path::PathBuf,
831
832        /// PostgreSQL URL for cursor reads (defaults to `fraiseql.toml` /
833        /// `DATABASE_URL`). Without one, definitions are listed with no cursor state.
834        #[arg(long)]
835        db_url: Option<String>,
836
837        /// Output machine-readable JSON.
838        #[arg(long)]
839        json: bool,
840    },
841
842    /// Author-side function tooling (local test harness).
843    #[cfg(feature = "functions-invoke")]
844    #[command(subcommand)]
845    Functions(FunctionsCommands),
846}
847
848/// `fraiseql functions` subcommands.
849#[cfg(feature = "functions-invoke")]
850#[derive(Subcommand)]
851pub(crate) enum FunctionsCommands {
852    /// Run a compiled function in a real V8 isolate against a fixture payload,
853    /// with mocked host ops — the author's inner test loop.
854    ///
855    /// Exit codes: 0 = ran; 3 = the `when` predicate did not match (nothing would
856    /// fire); 4 = the guest errored; 1 = a config/harness error.
857    #[command(long_about = "\
858Run a compiled function against a fixture payload in a real V8 isolate.
859
860The module is loaded exactly as the server loads it (from the compiled schema's
861module_dir); host ops are mocked so no live server, database, or network is needed.
862Every host-op call the function makes is printed.
863
864EXAMPLES:
865    fraiseql functions invoke notifyApproved --payload event.json
866    fraiseql functions invoke notifyApproved --payload event.json --explain
867    fraiseql functions invoke syncDeal --payload deal.json \\
868        --mock-http http.json --mock-query query.json")]
869    Invoke {
870        /// The function name (matches a definition in the compiled schema).
871        name: String,
872
873        /// Path to the fixture payload JSON. For after:mutation / after:capture:
874        /// `{ \"event_kind\": \"update\", \"old\": {…}, \"new\": {…} }` (a bare
875        /// object is treated as an insert's `new` image).
876        #[arg(long)]
877        payload: std::path::PathBuf,
878
879        /// Path to schema.compiled.json.
880        #[arg(long, default_value = "schema.compiled.json")]
881        schema: std::path::PathBuf,
882
883        /// Canned HTTP responses (JSON array of `{url_contains, method?, status?, body?}`);
884        /// a request matching none fails loud.
885        #[arg(long)]
886        mock_http: Option<std::path::PathBuf>,
887
888        /// Canned query responses (JSON array of `{query_contains?, variables?, response}`)
889        /// for the `fraiseql_query` bridge. An entry with `variables` matches only a call
890        /// bound with exactly those variables; a query matching none fails loud.
891        #[arg(long)]
892        mock_query: Option<std::path::PathBuf>,
893
894        /// Inject a fixed idempotency token the guest reads via the host op.
895        #[arg(long)]
896        idempotency_token: Option<String>,
897
898        /// Print why the `when` predicates did or did not match this payload.
899        #[arg(long)]
900        explain: bool,
901    },
902}
903
904/// `fraiseql perf` subcommands.
905#[derive(Subcommand)]
906pub(crate) enum PerfCommands {
907    /// Flag mutations whose latency regressed between a baseline and a recent
908    /// window, per `(object_type, modification_type)`.
909    ///
910    /// Only rows carrying the current `duration_calc_version` are compared, and
911    /// latency is never aggregated across modification types — so a shift in the
912    /// operation mix cannot mask a regression as a false improvement. Exits 0 even
913    /// when regressions are found (it is a report); pass `--fail-on-regression`
914    /// for CI gating.
915    RegressionScan {
916        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
917        #[arg(long, value_name = "DATABASE_URL")]
918        database: Option<String>,
919
920        /// Recent window size, in days.
921        #[arg(long, default_value_t = 7)]
922        recent_days: i64,
923
924        /// Baseline window size (immediately before the recent window), in days.
925        #[arg(long, default_value_t = 7)]
926        baseline_days: i64,
927
928        /// Minimum comparable samples required on each side; thinner groups are
929        /// skipped rather than flagged.
930        #[arg(long, default_value_t = 20)]
931        min_samples: usize,
932
933        /// Minimum p50 increase to flag, in percent.
934        #[arg(long, default_value_t = 25.0)]
935        threshold_pct: f64,
936
937        /// Minimum absolute p50 increase to flag, in milliseconds (the noise floor).
938        #[arg(long, default_value_t = 5.0)]
939        min_delta_ms: f64,
940
941        /// Restrict the scan to a single object_type.
942        #[arg(long, value_name = "TYPE")]
943        object_type: Option<String>,
944
945        /// Exit non-zero when any regression is found (for CI gating).
946        #[arg(long)]
947        fail_on_regression: bool,
948    },
949
950    /// Ad-hoc forensic reads of the change-log.
951    Explore {
952        #[command(subcommand)]
953        command: PerfExploreCommands,
954    },
955}
956
957/// `fraiseql perf explore` subcommands — read-only views over comparable (v2)
958/// change-log rows in a trailing window.
959#[derive(Subcommand)]
960pub(crate) enum PerfExploreCommands {
961    /// The slowest individual mutations, slowest first.
962    Slowest {
963        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
964        #[arg(long, value_name = "DATABASE_URL")]
965        database: Option<String>,
966
967        /// Trailing window to scan, in days.
968        #[arg(long, default_value_t = 7)]
969        days: i32,
970
971        /// Restrict to a single object_type.
972        #[arg(long, value_name = "TYPE")]
973        object_type: Option<String>,
974
975        /// Maximum rows to return.
976        #[arg(long, default_value_t = 20)]
977        limit: usize,
978    },
979
980    /// `duration_ms` completeness (NULL / pre-fix rates), per operation.
981    NullRate {
982        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
983        #[arg(long, value_name = "DATABASE_URL")]
984        database: Option<String>,
985
986        /// Trailing window to scan, in days.
987        #[arg(long, default_value_t = 7)]
988        days: i32,
989
990        /// Restrict to a single object_type.
991        #[arg(long, value_name = "TYPE")]
992        object_type: Option<String>,
993    },
994
995    /// Latency percentiles (p50/p95/p99/max), per operation.
996    Summary {
997        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
998        #[arg(long, value_name = "DATABASE_URL")]
999        database: Option<String>,
1000
1001        /// Trailing window to scan, in days.
1002        #[arg(long, default_value_t = 7)]
1003        days: i32,
1004
1005        /// Restrict to a single object_type.
1006        #[arg(long, value_name = "TYPE")]
1007        object_type: Option<String>,
1008    },
1009}
1010
1011#[derive(Subcommand)]
1012pub(crate) enum ValidateCommands {
1013    /// Validate that declared fact tables match database schema
1014    Facts {
1015        /// Schema.json file path
1016        #[arg(short, long, value_name = "SCHEMA")]
1017        schema: String,
1018
1019        /// Database connection string
1020        // Long-only: `-d` is the global `--debug` short (#650).
1021        #[arg(long, value_name = "DATABASE_URL")]
1022        database: String,
1023    },
1024}
1025
1026#[derive(Subcommand)]
1027pub(crate) enum GenerateClientCommands {
1028    /// Generate a TypeScript client (interfaces + typed query/mutation functions).
1029    Typescript {
1030        /// Path to schema.compiled.json (auto-detected from conventional
1031        /// locations if omitted).
1032        #[arg(long, value_name = "SCHEMA")]
1033        schema: Option<std::path::PathBuf>,
1034
1035        /// Output directory for the generated client.
1036        #[arg(long, value_name = "DIR")]
1037        out: std::path::PathBuf,
1038
1039        /// Overwrite an existing generated client in the output directory.
1040        #[arg(long, default_value_t = false)]
1041        force: bool,
1042    },
1043
1044    /// Generate a Python client (TypedDicts + typed query/mutation functions;
1045    /// requires Python >= 3.12).
1046    Python {
1047        /// Path to schema.compiled.json (auto-detected from conventional
1048        /// locations if omitted).
1049        #[arg(long, value_name = "SCHEMA")]
1050        schema: Option<std::path::PathBuf>,
1051
1052        /// Output directory for the generated client (becomes a Python package).
1053        #[arg(long, value_name = "DIR")]
1054        out: std::path::PathBuf,
1055
1056        /// Overwrite an existing generated client in the output directory.
1057        #[arg(long, default_value_t = false)]
1058        force: bool,
1059    },
1060
1061    /// Generate a Go client (structs + typed query/mutation functions; requires
1062    /// Go >= 1.21 for the Relay `Connection[T]`).
1063    Go {
1064        /// Path to schema.compiled.json (auto-detected from conventional
1065        /// locations if omitted).
1066        #[arg(long, value_name = "SCHEMA")]
1067        schema: Option<std::path::PathBuf>,
1068
1069        /// Output directory for the generated client (becomes package
1070        /// `fraiseqlclient`).
1071        #[arg(long, value_name = "DIR")]
1072        out: std::path::PathBuf,
1073
1074        /// Overwrite an existing generated client in the output directory.
1075        #[arg(long, default_value_t = false)]
1076        force: bool,
1077    },
1078
1079    /// Generate a Rust client (serde types + typed query/mutation functions;
1080    /// depends only on serde and serde_json).
1081    Rust {
1082        /// Path to schema.compiled.json (auto-detected from conventional
1083        /// locations if omitted).
1084        #[arg(long, value_name = "SCHEMA")]
1085        schema: Option<std::path::PathBuf>,
1086
1087        /// Output directory for the generated client (becomes a module tree with
1088        /// its own `mod.rs`).
1089        #[arg(long, value_name = "DIR")]
1090        out: std::path::PathBuf,
1091
1092        /// Overwrite an existing generated client in the output directory.
1093        #[arg(long, default_value_t = false)]
1094        force: bool,
1095    },
1096}
1097
1098#[derive(Subcommand)]
1099pub(crate) enum FederationCommands {
1100    /// Draw the federation the given subgraphs form
1101    Graph {
1102        /// Each subgraph's schema.compiled.json
1103        #[arg(value_name = "SCHEMA", required = true, num_args = 1..)]
1104        schema: Vec<String>,
1105
1106        /// Output format (json, dot, mermaid)
1107        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
1108        format: String,
1109    },
1110
1111    /// Print a subgraph's federation SDL, exactly as `_service { sdl }` serves it
1112    ///
1113    /// Reads the compiled schema; no server, database or credentials are needed.
1114    #[cfg(feature = "federation")]
1115    Sdl {
1116        /// Path to the subgraph's schema.compiled.json
1117        #[arg(value_name = "SCHEMA")]
1118        schema: std::path::PathBuf,
1119
1120        /// Write the SDL to this file instead of stdout
1121        #[arg(short, long, value_name = "FILE")]
1122        output: Option<std::path::PathBuf>,
1123    },
1124
1125    /// Validate a subgraph's federation entities and keys
1126    Check {
1127        /// Path to local schema.compiled.json
1128        #[arg(value_name = "SCHEMA")]
1129        schema: String,
1130
1131        /// Another subgraph's schema.compiled.json to compare with (@key agreement,
1132        /// field sharing)
1133        #[arg(short, long, value_name = "OTHER_SCHEMA")]
1134        against: Option<String>,
1135
1136        /// Output result as JSON
1137        #[arg(long)]
1138        json: bool,
1139    },
1140}
1141
1142#[derive(Subcommand)]
1143pub(crate) enum IntrospectCommands {
1144    /// Introspect database for fact tables (tf_* tables)
1145    Facts {
1146        /// Database connection string
1147        // Long-only: `-d` is the global `--debug` short (#650).
1148        #[arg(long, value_name = "DATABASE_URL")]
1149        database: String,
1150
1151        /// Output format (python, json)
1152        #[arg(short, long, value_name = "FORMAT", default_value = "python")]
1153        format: String,
1154    },
1155}
1156
1157#[derive(Subcommand)]
1158pub(crate) enum SchemaCommands {
1159    /// Display field-level security metadata from a running server
1160    Metadata {
1161        /// Server base URL
1162        #[arg(
1163            short,
1164            long,
1165            value_name = "URL",
1166            default_value = "http://localhost:8080"
1167        )]
1168        server: String,
1169
1170        /// Bearer token for authentication
1171        #[arg(short, long, value_name = "TOKEN")]
1172        token: Option<String>,
1173    },
1174}
1175
1176#[derive(Subcommand)]
1177pub(crate) enum MigrateCommands {
1178    /// Apply pending migrations
1179    Up {
1180        /// Database connection URL
1181        #[arg(long, value_name = "DATABASE_URL")]
1182        database: Option<String>,
1183
1184        /// Migration directory
1185        #[arg(long, value_name = "DIR")]
1186        dir: Option<String>,
1187    },
1188
1189    /// Roll back migrations
1190    Down {
1191        /// Database connection URL
1192        #[arg(long, value_name = "DATABASE_URL")]
1193        database: Option<String>,
1194
1195        /// Migration directory
1196        #[arg(long, value_name = "DIR")]
1197        dir: Option<String>,
1198
1199        /// Number of migrations to roll back
1200        #[arg(long, default_value = "1")]
1201        steps: u32,
1202    },
1203
1204    /// Show migration status
1205    Status {
1206        /// Database connection URL
1207        #[arg(long, value_name = "DATABASE_URL")]
1208        database: Option<String>,
1209
1210        /// Migration directory
1211        #[arg(long, value_name = "DIR")]
1212        dir: Option<String>,
1213    },
1214
1215    /// Create a new migration file
1216    ///
1217    /// Delegates to `confiture migrate generate`, like `generate`; kept as the
1218    /// verb the examples above use.
1219    Create {
1220        /// Migration name
1221        #[arg(value_name = "NAME")]
1222        name: String,
1223
1224        /// Migration directory
1225        #[arg(long, value_name = "DIR")]
1226        dir: Option<String>,
1227    },
1228
1229    /// Generate a new migration from schema diff
1230    ///
1231    /// Delegates to `confiture migrate generate`.
1232    /// Creates a timestamped migration file.
1233    Generate {
1234        /// Migration name
1235        #[arg(value_name = "NAME")]
1236        name: String,
1237
1238        /// Migration directory
1239        #[arg(long, value_name = "DIR")]
1240        dir: Option<String>,
1241    },
1242
1243    /// Validate migration files for naming, idempotency, and drift
1244    ///
1245    /// Delegates to `confiture migrate validate`.
1246    /// Checks naming conventions, idempotency, and schema drift.
1247    Validate {
1248        /// Migration directory
1249        #[arg(long, value_name = "DIR")]
1250        dir: Option<String>,
1251    },
1252
1253    /// Pre-deploy safety check on pending migrations
1254    ///
1255    /// Delegates to `confiture migrate preflight`.
1256    /// Verifies reversibility, detects non-transactional statements, checks checksums.
1257    Preflight {
1258        /// Migration directory
1259        #[arg(long, value_name = "DIR")]
1260        dir: Option<String>,
1261    },
1262}
1263
1264/// `fraiseql auth` subcommands.
1265#[cfg(feature = "run-server")]
1266#[derive(Subcommand)]
1267pub(crate) enum AuthCommands {
1268    /// Retire the first-party issuer's active signing key and activate a new one
1269    ///
1270    /// Reads what the server reads: `[auth_issuer]` from the server configuration file
1271    /// (--config, else FRAISEQL_CONFIG), `[auth.database]` from fraiseql.toml, the auth
1272    /// database URL from the variable its `url_env` names, and the key-encryption key from
1273    /// the variable `[auth_issuer]` names. The retired key stays published until the sessions
1274    /// it signed have expired. Running servers sign with the new key after their next hourly
1275    /// re-read, or at once after `POST /admin/v1/auth/refresh-jwks`. It never creates the
1276    /// table or the first key: a server with `[auth_issuer]` does when it boots.
1277    #[command(after_help = "\
1278EXAMPLES:
1279    FRAISEQL_CONFIG=server.toml fraiseql auth rotate-signing-key
1280    fraiseql auth rotate-signing-key fraiseql.toml --config server.toml")]
1281    RotateSigningKey {
1282        /// Input file path (fraiseql.toml or schema.json); auto-detected if omitted
1283        #[arg(value_name = "INPUT")]
1284        input: Option<String>,
1285
1286        /// The server configuration file (default: the file FRAISEQL_CONFIG names)
1287        #[arg(long, value_name = "PATH")]
1288        config: Option<String>,
1289    },
1290}