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}