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 for indexed column validation
97        /// When provided, validates that indexed columns exist in database views
98        #[arg(long, value_name = "DATABASE_URL")]
99        database: Option<String>,
100
101        /// Emit DDL files for all schema types to the given directory
102        ///
103        /// Writes `CREATE TABLE` DDL for each compiled type to `<DIR>/<type>.sql`.
104        /// Output is compatible with confiture's `db/schema/` directory format,
105        /// enabling `fraiseql migrate generate` to auto-detect schema drift.
106        #[arg(long, value_name = "DIR")]
107        emit_ddl: Option<String>,
108
109        /// Check compiled schema against the database for migration drift
110        ///
111        /// Emits DDL to a temporary directory, then delegates to
112        /// `confiture migrate validate` for drift detection.
113        /// Exits non-zero when the compiled schema diverges from the database.
114        #[arg(long)]
115        check_migrations: bool,
116    },
117
118    /// Extract schema from annotated source files
119    ///
120    /// Parses FraiseQL annotations in any supported language and generates schema.json.
121    /// No language runtime required — pure text processing.
122    #[command(after_help = "\
123EXAMPLES:
124    fraiseql extract schema/schema.py
125    fraiseql extract schema/ --recursive
126    fraiseql extract schema.rs --language rust -o schema.json")]
127    Extract {
128        /// Source file(s) or directory to extract from
129        #[arg(value_name = "INPUT")]
130        input: Vec<String>,
131
132        /// Override language detection (python, typescript, rust, java, kotlin, go, csharp, swift,
133        /// scala)
134        #[arg(short, long)]
135        language: Option<String>,
136
137        /// Recursively scan directories
138        #[arg(short, long)]
139        recursive: bool,
140
141        /// Output file path
142        #[arg(short, long, default_value = "schema.json")]
143        output: String,
144    },
145
146    /// Explain query execution plan and complexity
147    ///
148    /// Shows GraphQL query execution plan, SQL, and complexity analysis.
149    #[command(after_help = "\
150EXAMPLES:
151    fraiseql explain '{ users { id name } }'
152    fraiseql explain '{ user(id: 1) { posts { title } } }' --json")]
153    Explain {
154        /// GraphQL query string
155        #[arg(value_name = "QUERY")]
156        query: String,
157    },
158
159    /// Calculate query complexity score
160    ///
161    /// Quick analysis of query complexity (depth, field count, score).
162    #[command(after_help = "\
163EXAMPLES:
164    fraiseql cost '{ users { id name } }'
165    fraiseql cost '{ deeply { nested { query { here } } } }' --json")]
166    Cost {
167        /// GraphQL query string
168        #[arg(value_name = "QUERY")]
169        query: String,
170    },
171
172    /// Analyze schema for optimization opportunities
173    ///
174    /// Provides recommendations across 6 categories:
175    /// performance, security, federation, complexity, caching, indexing
176    #[command(after_help = "\
177EXAMPLES:
178    fraiseql analyze schema.compiled.json
179    fraiseql analyze schema.compiled.json --json")]
180    Analyze {
181        /// Path to schema.compiled.json
182        #[arg(value_name = "SCHEMA")]
183        schema: String,
184    },
185
186    /// Analyze schema type dependencies
187    ///
188    /// Exports dependency graph, detects cycles, and finds unused types.
189    /// Supports multiple output formats for visualization and CI integration.
190    #[command(after_help = "\
191EXAMPLES:
192    fraiseql dependency-graph schema.compiled.json
193    fraiseql dependency-graph schema.compiled.json -f dot > graph.dot
194    fraiseql dependency-graph schema.compiled.json -f mermaid
195    fraiseql dependency-graph schema.compiled.json --json")]
196    DependencyGraph {
197        /// Path to schema.compiled.json
198        #[arg(value_name = "SCHEMA")]
199        schema: String,
200
201        /// Output format (json, dot, mermaid, d2, console)
202        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
203        format: String,
204    },
205
206    /// Export federation dependency graph
207    ///
208    /// Visualize federation structure in multiple formats.
209    #[command(after_help = "\
210EXAMPLES:
211    fraiseql federation graph schema.compiled.json
212    fraiseql federation graph schema.compiled.json -f dot
213    fraiseql federation graph schema.compiled.json -f mermaid")]
214    Federation {
215        /// Schema path (positional argument passed to subcommand)
216        #[command(subcommand)]
217        command: FederationCommands,
218    },
219
220    /// Lint schema for FraiseQL design quality
221    ///
222    /// Analyzes schema using FraiseQL-calibrated design rules.
223    /// Detects JSONB batching issues, compilation problems, auth boundaries, etc.
224    #[command(after_help = "\
225EXAMPLES:
226    fraiseql lint schema.json
227    fraiseql lint schema.compiled.json --federation
228    fraiseql lint schema.json --fail-on-critical
229    fraiseql lint schema.json --json")]
230    Lint {
231        /// Path to schema.json or schema.compiled.json
232        #[arg(value_name = "SCHEMA")]
233        schema: String,
234
235        /// Only show federation audit
236        #[arg(long)]
237        federation: bool,
238
239        /// Only show cost audit
240        #[arg(long)]
241        cost: bool,
242
243        /// Only show cache audit
244        #[arg(long)]
245        cache: bool,
246
247        /// Only show auth audit
248        #[arg(long)]
249        auth: bool,
250
251        /// Only show compilation audit
252        #[arg(long)]
253        compilation: bool,
254
255        /// Exit with error if any critical issues found
256        #[arg(long)]
257        fail_on_critical: bool,
258
259        /// Exit with error if any warning or critical issues found
260        #[arg(long)]
261        fail_on_warning: bool,
262    },
263
264    /// Generate DDL for Arrow views (va_*, tv_*, ta_*)
265    #[command(after_help = "\
266EXAMPLES:
267    fraiseql generate-views -s schema.json -e User --view va_users
268    fraiseql generate-views -s schema.json -e Order --view tv_orders --refresh-strategy scheduled")]
269    GenerateViews {
270        /// Path to schema.json
271        #[arg(short, long, value_name = "SCHEMA")]
272        schema: String,
273
274        /// Entity name from schema
275        #[arg(short, long, value_name = "NAME")]
276        entity: String,
277
278        /// View name (must start with va_, tv_, or ta_)
279        #[arg(long, value_name = "NAME")]
280        view: String,
281
282        /// Refresh strategy (trigger-based or scheduled)
283        #[arg(long, value_name = "STRATEGY", default_value = "trigger-based")]
284        refresh_strategy: String,
285
286        /// Output file path (default: {view}.sql)
287        #[arg(short, long, value_name = "PATH")]
288        output: Option<String>,
289
290        /// Include helper/composition views
291        #[arg(long, default_value = "true")]
292        include_composition_views: bool,
293
294        /// Include monitoring functions
295        #[arg(long, default_value = "true")]
296        include_monitoring: bool,
297
298        /// Validate only, don't write file
299        #[arg(long)]
300        validate: bool,
301
302        /// Show generation steps (use global --verbose flag)
303        #[arg(long, action = clap::ArgAction::SetTrue)]
304        gen_verbose: bool,
305    },
306
307    /// Generate external-write capture-trigger DDL for @subscribable types (#366)
308    ///
309    /// Emits a self-contained, idempotent PostgreSQL script (the capture function
310    /// plus per-table statement-level triggers) that brings raw external writes
311    /// (psql / migrations / third-party tools) to a `@subscribable` type's table(s)
312    /// onto the Change Spine, without double-emitting for FraiseQL-mediated writes.
313    #[command(after_help = "\
314EXAMPLES:
315    fraiseql generate-capture-triggers -s schema.compiled.json | psql \"$DATABASE_URL\"
316    fraiseql generate-capture-triggers -s schema.compiled.json -o capture.sql")]
317    GenerateCaptureTriggers {
318        /// Path to the compiled schema (schema.compiled.json)
319        #[arg(short, long, value_name = "SCHEMA")]
320        schema: String,
321
322        /// Output file path (default: stdout, for piping into psql)
323        #[arg(short, long, value_name = "PATH")]
324        output: Option<String>,
325
326        /// Omit the capture function definition (emit only the per-table triggers)
327        #[arg(long)]
328        no_function: bool,
329    },
330
331    /// Validate schema.json or fact tables
332    ///
333    /// Performs comprehensive schema validation including:
334    /// - JSON structure validation
335    /// - Type reference validation
336    /// - Circular dependency detection (with --check-cycles)
337    /// - Unused type detection (with --check-unused)
338    #[command(after_help = "\
339EXAMPLES:
340    fraiseql validate schema.json
341    fraiseql validate schema.json --check-unused
342    fraiseql validate schema.json --strict
343    fraiseql validate facts -s schema.json -d postgres://localhost/db")]
344    Validate {
345        #[command(subcommand)]
346        command: Option<ValidateCommands>,
347
348        /// Schema.json file path to validate (if no subcommand)
349        #[arg(value_name = "INPUT")]
350        input: Option<String>,
351
352        /// Check for circular dependencies between types
353        #[arg(long, default_value = "true")]
354        check_cycles: bool,
355
356        /// Check for unused types (no incoming references)
357        #[arg(long)]
358        check_unused: bool,
359
360        /// Strict mode: treat warnings as errors (unused types become errors)
361        #[arg(long)]
362        strict: bool,
363
364        /// Only analyze specific type(s) - comma-separated list
365        #[arg(long, value_name = "TYPES", value_delimiter = ',')]
366        types: Vec<String>,
367
368        /// Validate the mutation contract against a live PostgreSQL database.
369        ///
370        /// For each DB-backed mutation, checks that its `sql_source` function
371        /// exists with the argument layout the server sends and returns the
372        /// columns the server decodes into `MutationResponse`. Requires a
373        /// compiled schema (schema.compiled.json) as INPUT. (#397)
374        #[arg(long, value_name = "DATABASE_URL")]
375        against_db: Option<String>,
376    },
377
378    /// Introspect database for fact tables and output suggestions
379    #[command(after_help = "\
380EXAMPLES:
381    fraiseql introspect facts -d postgres://localhost/db
382    fraiseql introspect facts -d postgres://localhost/db -f json")]
383    Introspect {
384        #[command(subcommand)]
385        command: IntrospectCommands,
386    },
387
388    /// Generate authoring-language source from schema.json
389    ///
390    /// The inverse of `fraiseql extract`: reads a schema.json and produces annotated
391    /// source code in any of the 9 supported authoring languages.
392    #[command(after_help = "\
393EXAMPLES:
394    fraiseql generate schema.json --language python
395    fraiseql generate schema.json --language rust -o schema.rs
396    fraiseql generate schema.json --language typescript")]
397    Generate {
398        /// Path to schema.json
399        #[arg(value_name = "INPUT")]
400        input: String,
401
402        /// Target language (python, typescript, rust, java, kotlin, go, csharp, swift, scala)
403        #[arg(short, long)]
404        language: String,
405
406        /// Output file path (default: schema.<ext> based on language)
407        #[arg(short, long)]
408        output: Option<String>,
409    },
410
411    /// Generate consumer-side typed clients from a compiled schema
412    ///
413    /// Distinct from `generate`: `generate` emits server-side authoring code (write
414    /// FraiseQL types in another language, fed back into the compiler), whereas
415    /// `generate-client` emits a typed client for callers of a FraiseQL API
416    /// (interfaces + query/mutation functions) from a compiled schema.compiled.json.
417    #[command(after_help = "\
418EXAMPLES:
419    fraiseql generate-client typescript --out ./src/generated
420    fraiseql generate-client typescript --schema ./schema.compiled.json --out ./gen --force")]
421    GenerateClient {
422        #[command(subcommand)]
423        language: GenerateClientCommands,
424    },
425
426    /// Initialize a new FraiseQL project
427    ///
428    /// Creates project directory with fraiseql.toml, schema.json,
429    /// database DDL structure, and authoring skeleton.
430    #[command(after_help = "\
431EXAMPLES:
432    fraiseql init my-app
433    fraiseql init my-app --language typescript --database postgres
434    fraiseql init my-app --size xs --no-git")]
435    Init {
436        /// Project name (used as directory name)
437        #[arg(value_name = "PROJECT_NAME")]
438        project_name: String,
439
440        /// Authoring language (python, typescript, rust, java, kotlin, go, csharp, swift, scala)
441        #[arg(short, long, default_value = "python")]
442        language: String,
443
444        /// Target database (postgres, mysql, sqlite, sqlserver)
445        #[arg(long, default_value = "postgres")]
446        database: String,
447
448        /// Project size: xs (single file), s (flat dirs), m (per-entity dirs)
449        #[arg(long, default_value = "s")]
450        size: String,
451
452        /// Skip git init
453        #[arg(long)]
454        no_git: bool,
455    },
456
457    /// Run database migrations
458    ///
459    /// Wraps confiture for a unified migration experience.
460    /// Reads database URL from --database, fraiseql.toml, or DATABASE_URL env var.
461    #[command(after_help = "\
462EXAMPLES:
463    fraiseql migrate up --database postgres://localhost/mydb
464    fraiseql migrate down --steps 1
465    fraiseql migrate status
466    fraiseql migrate create add_posts_table")]
467    Migrate {
468        #[command(subcommand)]
469        command: MigrateCommands,
470    },
471
472    /// Generate Software Bill of Materials
473    ///
474    /// Parses Cargo.lock and fraiseql.toml to produce a compliance-ready SBOM.
475    #[command(after_help = "\
476EXAMPLES:
477    fraiseql sbom
478    fraiseql sbom --format spdx
479    fraiseql sbom --format cyclonedx --output sbom.json")]
480    Sbom {
481        /// Output format (cyclonedx, spdx)
482        #[arg(short, long, default_value = "cyclonedx")]
483        format: String,
484
485        /// Output file path (default: stdout)
486        #[arg(short, long, value_name = "FILE")]
487        output: Option<String>,
488    },
489
490    /// Compile schema and immediately start the GraphQL server
491    ///
492    /// Compiles the schema in-memory (no disk artifact) and starts the HTTP server.
493    /// With --watch, the server hot-reloads whenever the schema file changes.
494    ///
495    /// Server and database settings can be declared in fraiseql.toml under [server]
496    /// and [database] sections.  CLI flags take precedence over TOML settings, which
497    /// take precedence over defaults.  The database URL is resolved in this order:
498    /// --database flag > DATABASE_URL env var > [database].url in fraiseql.toml.
499    #[cfg(feature = "run-server")]
500    #[command(after_help = "\
501EXAMPLES:
502    fraiseql run
503    fraiseql run fraiseql.toml --database postgres://localhost/mydb
504    fraiseql run --port 3000 --watch
505    fraiseql run schema.json --introspection
506
507TOML CONFIG:
508    [server]
509    host = \"127.0.0.1\"
510    port = 9000
511
512    [server.cors]
513    origins = [\"https://app.example.com\"]
514
515    [database]
516    url      = \"${DATABASE_URL}\"
517    pool_min = 2
518    pool_max = 20")]
519    Run {
520        /// Input file path (fraiseql.toml or schema.json); auto-detected if omitted
521        #[arg(value_name = "INPUT")]
522        input: Option<String>,
523
524        /// Database URL (overrides [database].url in fraiseql.toml and DATABASE_URL env var)
525        #[arg(short, long, value_name = "DATABASE_URL")]
526        database: Option<String>,
527
528        /// Port to listen on (overrides [server].port in fraiseql.toml)
529        #[arg(short, long, value_name = "PORT")]
530        port: Option<u16>,
531
532        /// Bind address (overrides [server].host in fraiseql.toml)
533        #[arg(long, value_name = "HOST")]
534        bind: Option<String>,
535
536        /// Watch input file for changes and hot-reload the server
537        #[arg(short, long)]
538        watch: bool,
539
540        /// Enable the GraphQL introspection endpoint (no auth required)
541        #[arg(long)]
542        introspection: bool,
543    },
544
545    /// Watch a schema source and recompile + live-reload a running server on change
546    ///
547    /// Recompiles `schema.compiled.json` on every save and, with `--reload-url`,
548    /// POSTs it to a running server's `/api/v1/admin/reload-schema` endpoint for a
549    /// zero-downtime `ArcSwap` reload (no restart). Unlike `run --watch`, this drives
550    /// a server you started separately.
551    Watch {
552        /// Schema source to watch (fraiseql.toml or schema.json)
553        #[arg(value_name = "INPUT")]
554        input: String,
555
556        /// Compiled-schema output path (the server reads this on reload)
557        #[arg(short, long, default_value = "schema.compiled.json")]
558        output: String,
559
560        /// Base URL of a running server to live-reload (e.g. http://localhost:8080).
561        /// Omit to recompile to disk only.
562        #[arg(long, value_name = "URL")]
563        reload_url: Option<String>,
564
565        /// Bearer token for the server's admin reload endpoint
566        #[arg(long, value_name = "TOKEN")]
567        admin_token: Option<String>,
568
569        /// Database URL for compile-time validation (overrides DATABASE_URL)
570        #[arg(long, value_name = "DATABASE_URL")]
571        database: Option<String>,
572    },
573
574    /// Validate a trusted documents manifest
575    ///
576    /// Checks that the manifest JSON is well-formed and that each key
577    /// is a valid SHA-256 hex string matching its query body.
578    #[command(after_help = "\
579EXAMPLES:
580    fraiseql validate-documents manifest.json")]
581    ValidateDocuments {
582        /// Path to the trusted documents manifest JSON file
583        #[arg(value_name = "MANIFEST")]
584        manifest: String,
585    },
586
587    /// Install FraiseQL mutation helper functions
588    ///
589    /// Installs SQL helper functions (fraiseql.mutation_ok, fraiseql.mutation_err, etc.)
590    /// to reduce boilerplate when writing mutation functions under the v2.2.0 protocol.
591    /// The helpers are installed in the `fraiseql` schema, which is owned by FraiseQL's
592    /// database role.
593    #[command(after_help = "\
594EXAMPLES:
595    fraiseql setup --database postgres://localhost/mydb
596    fraiseql setup --dry-run
597    fraiseql setup  # Uses DATABASE_URL or [database].url from fraiseql.toml")]
598    Setup {
599        /// Database connection URL (or use DATABASE_URL env var, or [database].url in
600        /// fraiseql.toml)
601        #[arg(long, value_name = "DATABASE_URL")]
602        database: Option<String>,
603
604        /// Print SQL without applying changes
605        #[arg(long)]
606        dry_run: bool,
607    },
608
609    /// Inspect schema metadata from a running FraiseQL server
610    ///
611    /// Fetches field-level security metadata (encryption, scope requirements, deny actions)
612    /// from the server's `/api/v1/schema/metadata` endpoint and displays it as a table.
613    #[command(after_help = "\
614EXAMPLES:
615    fraiseql schema metadata
616    fraiseql schema metadata --server http://localhost:8080
617    fraiseql schema metadata --server https://api.example.com --token mytoken")]
618    Schema {
619        #[command(subcommand)]
620        command: SchemaCommands,
621    },
622
623    /// Execute one GraphQL operation against a database and print the JSON result
624    ///
625    /// Boots the compiled schema in-process (no long-lived server, no HTTP layer)
626    /// against PostgreSQL, runs a single query or mutation, prints the GraphQL JSON
627    /// response to stdout, and exits. Exits non-zero on a resolution error — a
628    /// scriptable "does this actually resolve?" check for CI and the inner loop.
629    ///
630    /// Mutations COMMIT unless `--dry-run` is given, which runs the mutation inside
631    /// a transaction that is rolled back (validate-bind-without-commit).
632    #[command(after_help = "\
633EXAMPLES:
634    fraiseql query '{ orders(limit: 1) { id } }'
635    fraiseql query --schema schema.compiled.json --database postgres://localhost/db '{ users { id } }'
636    fraiseql query --variables '{\"id\": 42}' 'query($id: Int!) { user(id: $id) { id } }'
637    fraiseql query --dry-run 'mutation { createUser(name: \"x\") { id } }'")]
638    Query {
639        /// GraphQL operation to execute (a single query or mutation document).
640        #[arg(value_name = "QUERY")]
641        query: String,
642
643        /// Path to schema.compiled.json.
644        #[arg(short = 's', long, default_value = "schema.compiled.json")]
645        schema: std::path::PathBuf,
646
647        /// Database URL (PostgreSQL only). Falls back to the DATABASE_URL env var.
648        #[arg(long, value_name = "DATABASE_URL")]
649        database: Option<String>,
650
651        /// GraphQL variables as a JSON object string (e.g. '{"id": 42}').
652        #[arg(long, value_name = "JSON")]
653        variables: Option<String>,
654
655        /// Run mutations inside a rolled-back transaction: the function binds and
656        /// executes (constraints, triggers, and response shape are validated) but
657        /// nothing is committed. No effect on queries. PostgreSQL only.
658        #[arg(long)]
659        dry_run: bool,
660    },
661
662    /// Run diagnostic checks for common FraiseQL setup problems
663    ///
664    /// Checks schema file, TOML config, DATABASE_URL, JWT secret, Redis, TLS,
665    /// and cache/auth coherence. Prints a color-coded report and exits 0 if
666    /// all checks pass or 1 if any check fails (warnings do not fail).
667    #[command(after_help = "\
668EXAMPLES:
669    fraiseql doctor
670    fraiseql doctor --schema schema.compiled.json --config fraiseql.toml
671    fraiseql doctor --db-url postgres://user:pass@host:5432/db
672    fraiseql doctor --runtime --against-db postgres://user:pass@host:5432/db
673    fraiseql doctor --json")]
674    Doctor {
675        /// Path to fraiseql.toml configuration file.
676        #[arg(long, default_value = "fraiseql.toml")]
677        config: std::path::PathBuf,
678
679        /// Path to schema.compiled.json.
680        #[arg(long, default_value = "schema.compiled.json")]
681        schema: std::path::PathBuf,
682
683        /// Override DATABASE_URL for the connectivity check.
684        #[arg(long)]
685        db_url: Option<String>,
686
687        /// Run the live-database passes against this PostgreSQL database: the
688        /// change-log contract drift check (compares core.tb_entity_change_log
689        /// against the shipped contract — #380), the PL/pgSQL body-resolution
690        /// pass (reports internal calls that no longer resolve — #409), and the
691        /// mutation→function contract drift check (every declared mutation has a
692        /// matching backing function with a mutation_response-shaped return —
693        /// #384).
694        ///
695        /// The body-resolution pass requires the `plpgsql_check` extension and
696        /// skips with a hint when it is unavailable.
697        #[arg(long, value_name = "DATABASE_URL")]
698        against_db: Option<String>,
699
700        /// Runtime smoke mode (#501): boot the compiled schema in-process and
701        /// probe each root query field with a minimal selection, dry-running each
702        /// no-argument mutation (rolled back). Confirms operations actually
703        /// *resolve* end-to-end, complementing the static `--against-db` drift
704        /// checks. Requires a PostgreSQL URL via `--against-db` (or `--db-url`).
705        /// Queries / mutations that require arguments are skipped with a warning.
706        #[arg(long)]
707        runtime: bool,
708
709        /// Schemas to scan in the body-resolution pass (comma-separated).
710        #[arg(
711            long,
712            value_name = "SCHEMAS",
713            value_delimiter = ',',
714            default_value = "public"
715        )]
716        schemas: Vec<String>,
717
718        /// Output machine-readable JSON (for CI integration).
719        #[arg(long)]
720        json: bool,
721    },
722
723    /// Change-log performance observability (#392)
724    ///
725    /// Reads the framework-owned change-log (`core.v_entity_change_log`) and turns
726    /// it into operator-facing forensics: latency regressions between two time
727    /// windows, and ad-hoc reads of the slowest mutations / duration completeness
728    /// / per-operation percentiles.
729    Perf {
730        #[command(subcommand)]
731        command: PerfCommands,
732    },
733
734    /// List scheduled ingress sources and their durable cursor state (#573)
735    ///
736    /// Reads the compiled schema for each source's schedule and `run_as` ceiling,
737    /// and — when a database is reachable — its cursor value, version, and staleness
738    /// (seconds since the last advance). Leadership and last-fire are not reported
739    /// here (an advisory lock has no visible holder, and the lease is released
740    /// between ticks): watch `fraiseql_source_*` metrics and the poller logs for
741    /// those. Always exits 0 — it is a report.
742    #[command(after_help = "\
743EXAMPLES:
744    fraiseql sources
745    fraiseql sources --schema schema.compiled.json
746    fraiseql sources --db-url postgres://user:pass@host:5432/db
747    fraiseql sources --json")]
748    Sources {
749        /// Path to schema.compiled.json.
750        #[arg(long, default_value = "schema.compiled.json")]
751        schema: std::path::PathBuf,
752
753        /// PostgreSQL URL for cursor reads (defaults to `fraiseql.toml` /
754        /// `DATABASE_URL`). Without one, definitions are listed with no cursor state.
755        #[arg(long)]
756        db_url: Option<String>,
757
758        /// Output machine-readable JSON.
759        #[arg(long)]
760        json: bool,
761    },
762
763    /// Author-side function tooling (local test harness).
764    #[cfg(feature = "functions-invoke")]
765    #[command(subcommand)]
766    Functions(FunctionsCommands),
767}
768
769/// `fraiseql functions` subcommands.
770#[cfg(feature = "functions-invoke")]
771#[derive(Subcommand)]
772pub(crate) enum FunctionsCommands {
773    /// Run a compiled function in a real V8 isolate against a fixture payload,
774    /// with mocked host ops — the author's inner test loop.
775    ///
776    /// Exit codes: 0 = ran; 3 = the `when` predicate did not match (nothing would
777    /// fire); 4 = the guest errored; 1 = a config/harness error.
778    #[command(long_about = "\
779Run a compiled function against a fixture payload in a real V8 isolate.
780
781The module is loaded exactly as the server loads it (from the compiled schema's
782module_dir); host ops are mocked so no live server, database, or network is needed.
783Every host-op call the function makes is printed.
784
785EXAMPLES:
786    fraiseql functions invoke notifyApproved --payload event.json
787    fraiseql functions invoke notifyApproved --payload event.json --explain
788    fraiseql functions invoke syncDeal --payload deal.json \\
789        --mock-http http.json --mock-query query.json")]
790    Invoke {
791        /// The function name (matches a definition in the compiled schema).
792        name: String,
793
794        /// Path to the fixture payload JSON. For after:mutation / after:capture:
795        /// `{ \"event_kind\": \"update\", \"old\": {…}, \"new\": {…} }` (a bare
796        /// object is treated as an insert's `new` image).
797        #[arg(long)]
798        payload: std::path::PathBuf,
799
800        /// Path to schema.compiled.json.
801        #[arg(long, default_value = "schema.compiled.json")]
802        schema: std::path::PathBuf,
803
804        /// Canned HTTP responses (JSON array of `{url_contains, method?, status?, body?}`);
805        /// a request matching none fails loud.
806        #[arg(long)]
807        mock_http: Option<std::path::PathBuf>,
808
809        /// Canned query responses (JSON array of `{query_contains?, response}`) for the
810        /// `fraiseql_query` bridge; a query matching none fails loud.
811        #[arg(long)]
812        mock_query: Option<std::path::PathBuf>,
813
814        /// Inject a fixed idempotency token the guest reads via the host op.
815        #[arg(long)]
816        idempotency_token: Option<String>,
817
818        /// Print why the `when` predicates did or did not match this payload.
819        #[arg(long)]
820        explain: bool,
821    },
822}
823
824/// `fraiseql perf` subcommands.
825#[derive(Subcommand)]
826pub(crate) enum PerfCommands {
827    /// Flag mutations whose latency regressed between a baseline and a recent
828    /// window, per `(object_type, modification_type)`.
829    ///
830    /// Only rows carrying the current `duration_calc_version` are compared, and
831    /// latency is never aggregated across modification types — so a shift in the
832    /// operation mix cannot mask a regression as a false improvement. Exits 0 even
833    /// when regressions are found (it is a report); pass `--fail-on-regression`
834    /// for CI gating.
835    RegressionScan {
836        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
837        #[arg(long, value_name = "DATABASE_URL")]
838        database: Option<String>,
839
840        /// Recent window size, in days.
841        #[arg(long, default_value_t = 7)]
842        recent_days: i64,
843
844        /// Baseline window size (immediately before the recent window), in days.
845        #[arg(long, default_value_t = 7)]
846        baseline_days: i64,
847
848        /// Minimum comparable samples required on each side; thinner groups are
849        /// skipped rather than flagged.
850        #[arg(long, default_value_t = 20)]
851        min_samples: usize,
852
853        /// Minimum p50 increase to flag, in percent.
854        #[arg(long, default_value_t = 25.0)]
855        threshold_pct: f64,
856
857        /// Minimum absolute p50 increase to flag, in milliseconds (the noise floor).
858        #[arg(long, default_value_t = 5.0)]
859        min_delta_ms: f64,
860
861        /// Restrict the scan to a single object_type.
862        #[arg(long, value_name = "TYPE")]
863        object_type: Option<String>,
864
865        /// Exit non-zero when any regression is found (for CI gating).
866        #[arg(long)]
867        fail_on_regression: bool,
868    },
869
870    /// Ad-hoc forensic reads of the change-log.
871    Explore {
872        #[command(subcommand)]
873        command: PerfExploreCommands,
874    },
875}
876
877/// `fraiseql perf explore` subcommands — read-only views over comparable (v2)
878/// change-log rows in a trailing window.
879#[derive(Subcommand)]
880pub(crate) enum PerfExploreCommands {
881    /// The slowest individual mutations, slowest first.
882    Slowest {
883        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
884        #[arg(long, value_name = "DATABASE_URL")]
885        database: Option<String>,
886
887        /// Trailing window to scan, in days.
888        #[arg(long, default_value_t = 7)]
889        days: i32,
890
891        /// Restrict to a single object_type.
892        #[arg(long, value_name = "TYPE")]
893        object_type: Option<String>,
894
895        /// Maximum rows to return.
896        #[arg(long, default_value_t = 20)]
897        limit: usize,
898    },
899
900    /// `duration_ms` completeness (NULL / pre-fix rates), per operation.
901    NullRate {
902        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
903        #[arg(long, value_name = "DATABASE_URL")]
904        database: Option<String>,
905
906        /// Trailing window to scan, in days.
907        #[arg(long, default_value_t = 7)]
908        days: i32,
909
910        /// Restrict to a single object_type.
911        #[arg(long, value_name = "TYPE")]
912        object_type: Option<String>,
913    },
914
915    /// Latency percentiles (p50/p95/p99/max), per operation.
916    Summary {
917        /// PostgreSQL URL (defaults to `fraiseql.toml` / `DATABASE_URL`).
918        #[arg(long, value_name = "DATABASE_URL")]
919        database: Option<String>,
920
921        /// Trailing window to scan, in days.
922        #[arg(long, default_value_t = 7)]
923        days: i32,
924
925        /// Restrict to a single object_type.
926        #[arg(long, value_name = "TYPE")]
927        object_type: Option<String>,
928    },
929}
930
931#[derive(Subcommand)]
932pub(crate) enum ValidateCommands {
933    /// Validate that declared fact tables match database schema
934    Facts {
935        /// Schema.json file path
936        #[arg(short, long, value_name = "SCHEMA")]
937        schema: String,
938
939        /// Database connection string
940        #[arg(short, long, value_name = "DATABASE_URL")]
941        database: String,
942    },
943}
944
945#[derive(Subcommand)]
946pub(crate) enum GenerateClientCommands {
947    /// Generate a TypeScript client (interfaces + typed query/mutation functions).
948    Typescript {
949        /// Path to schema.compiled.json (auto-detected from conventional
950        /// locations if omitted).
951        #[arg(long, value_name = "SCHEMA")]
952        schema: Option<std::path::PathBuf>,
953
954        /// Output directory for the generated client.
955        #[arg(long, value_name = "DIR")]
956        out: std::path::PathBuf,
957
958        /// Overwrite an existing generated client in the output directory.
959        #[arg(long, default_value_t = false)]
960        force: bool,
961    },
962}
963
964#[derive(Subcommand)]
965pub(crate) enum FederationCommands {
966    /// Export federation graph
967    Graph {
968        /// Path to schema.compiled.json
969        #[arg(value_name = "SCHEMA")]
970        schema: String,
971
972        /// Output format (json, dot, mermaid)
973        #[arg(short, long, value_name = "FORMAT", default_value = "json")]
974        format: String,
975    },
976
977    /// Validate subgraph composition
978    Check {
979        /// Path to local schema.compiled.json
980        #[arg(value_name = "SCHEMA")]
981        schema: String,
982
983        /// Path to supergraph schema for composition validation
984        #[arg(short, long, value_name = "SUPERGRAPH")]
985        against: Option<String>,
986
987        /// Output result as JSON
988        #[arg(long)]
989        json: bool,
990    },
991}
992
993#[derive(Subcommand)]
994pub(crate) enum IntrospectCommands {
995    /// Introspect database for fact tables (tf_* tables)
996    Facts {
997        /// Database connection string
998        #[arg(short, long, value_name = "DATABASE_URL")]
999        database: String,
1000
1001        /// Output format (python, json)
1002        #[arg(short, long, value_name = "FORMAT", default_value = "python")]
1003        format: String,
1004    },
1005}
1006
1007#[derive(Subcommand)]
1008pub(crate) enum SchemaCommands {
1009    /// Display field-level security metadata from a running server
1010    Metadata {
1011        /// Server base URL
1012        #[arg(
1013            short,
1014            long,
1015            value_name = "URL",
1016            default_value = "http://localhost:8080"
1017        )]
1018        server: String,
1019
1020        /// Bearer token for authentication
1021        #[arg(short, long, value_name = "TOKEN")]
1022        token: Option<String>,
1023    },
1024}
1025
1026#[derive(Subcommand)]
1027pub(crate) enum MigrateCommands {
1028    /// Apply pending migrations
1029    Up {
1030        /// Database connection URL
1031        #[arg(long, value_name = "DATABASE_URL")]
1032        database: Option<String>,
1033
1034        /// Migration directory
1035        #[arg(long, value_name = "DIR")]
1036        dir: Option<String>,
1037    },
1038
1039    /// Roll back migrations
1040    Down {
1041        /// Database connection URL
1042        #[arg(long, value_name = "DATABASE_URL")]
1043        database: Option<String>,
1044
1045        /// Migration directory
1046        #[arg(long, value_name = "DIR")]
1047        dir: Option<String>,
1048
1049        /// Number of migrations to roll back
1050        #[arg(long, default_value = "1")]
1051        steps: u32,
1052    },
1053
1054    /// Show migration status
1055    Status {
1056        /// Database connection URL
1057        #[arg(long, value_name = "DATABASE_URL")]
1058        database: Option<String>,
1059
1060        /// Migration directory
1061        #[arg(long, value_name = "DIR")]
1062        dir: Option<String>,
1063    },
1064
1065    /// Create a new migration file
1066    Create {
1067        /// Migration name
1068        #[arg(value_name = "NAME")]
1069        name: String,
1070
1071        /// Migration directory
1072        #[arg(long, value_name = "DIR")]
1073        dir: Option<String>,
1074    },
1075
1076    /// Generate a new migration from schema diff
1077    ///
1078    /// Delegates to `confiture migrate generate`.
1079    /// Creates a timestamped migration file.
1080    Generate {
1081        /// Migration name
1082        #[arg(value_name = "NAME")]
1083        name: String,
1084
1085        /// Migration directory
1086        #[arg(long, value_name = "DIR")]
1087        dir: Option<String>,
1088    },
1089
1090    /// Validate migration files for naming, idempotency, and drift
1091    ///
1092    /// Delegates to `confiture migrate validate`.
1093    /// Checks naming conventions, idempotency, and schema drift.
1094    Validate {
1095        /// Migration directory
1096        #[arg(long, value_name = "DIR")]
1097        dir: Option<String>,
1098    },
1099
1100    /// Pre-deploy safety check on pending migrations
1101    ///
1102    /// Delegates to `confiture migrate preflight`.
1103    /// Verifies reversibility, detects non-transactional statements, checks checksums.
1104    Preflight {
1105        /// Migration directory
1106        #[arg(long, value_name = "DIR")]
1107        dir: Option<String>,
1108    },
1109}