From e1ca57002148533cdbba0c4a304b82dc608b7bff Mon Sep 17 00:00:00 2001 From: Kris Jenkins Date: Tue, 15 Sep 2026 10:33:41 +0100 Subject: [PATCH] =?UTF-8?q?Run=20`spacetime=20describe=20mydb`=20today=20a?= =?UTF-8?q?nd=20it=20refuses:=20`--json`=20is=20a=20required=20flag,=20and?= =?UTF-8?q?=20its=20help=20text=20promises=20that=20"in=20the=20future,=20?= =?UTF-8?q?omitting=20this=20will=20give=20human-readable=20output".=20Thi?= =?UTF-8?q?s=20patch=20brings=20that=20glorious=20future.=20=F0=9F=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spacetime describe [db]` now prints the module as readable text, in the sections Tables, Views, Reducers, Procedures, and Types: ``` page_previews (public) Columns: title String primary key page_id U64 rev_id U64 display_title String description Option extract String thumbnail Option fetched_at Timestamp Indexes: page_previews_title_idx_btree btree (title) ``` You can narrow it to every entity of one kind, or to one entity by name. The entity types are `tables`, `reducers`, `procedures` and `types`, matching the section headings: ``` $ spacetime describe wikiwatch procedures fetch_edits(timer: FetchEditsSchedule) [private] fetch_previews(timer: FetchPreviewsSchedule) [private] $ spacetime describe wikiwatch types Thumbnail Thumbnail = { url: String, width: U32, height: U32 } ``` The singular forms (`table person`) still work, since that's what the JSON-only command accepted. `--format json`, or the existing `--json` shorthand, prints the raw definitions instead: the whole module, an array for a listing, or a single def. Text is styled like the migration plan that `spacetime publish` prints, and is coloured only when stdout is a terminal and `NO_COLOR` is unset. The renderer is a new `describe` module in the schema crate, next to the migration printer whose look it shares. - `StyledWriter`: the colour scheme, colour/no-colour buffer and indent helpers move out of `TermColorFormatter` into a `pub(crate)` writer that both printers use. Migration output is unchanged, and its snapshots pass untouched. - `type_name` spells types language-neutrally: `U64`, `Array`, `Option`, `Timestamp`, and named types by their scoped name joined with `.`. It reads the "for generate" typespace, which keeps special types and refs intact. - Field and variant names come from the case-converted typespace, because `typespace_for_generate` keeps source names (`imageUrl`, not `image_url`). Column defaults are formatted through `WithTypespace`, so a sum prints as `(red = ())` rather than `( = ())`. - Reducer names arrive already qualified with their submodule (`lib.end_session`), while table, view, procedure and type names are local and have the prefix added. Prefixing reducers too would print `lib.lib.end_session`, and a test pins that. - Types lists only named types reachable from a column or a signature, and leaves out table row types, which their table's block already shows. `types ` searches every named type, so a row type seen in a signature (`FetchEditsSchedule`) can still be looked up. - Each listing (`describe_tables` and friends) shares its sorted source with the whole-module renderer, and tests pin each one to its module section. - CLI: `--format text|json` defaults to `text`, using a `Format` enum now shared with `sql` and `logs` in `common_args`. `--json` conflicts with an explicit `--format`. Whole-module JSON is still the raw, unvalidated def, returned before validation, so a module that fails validation can still be dumped for debugging. Existing JSON output is byte-for-byte unchanged. - Tests: insta snapshots of a fixture covering every section, submodules, indexes, constraints, defaults and schedules. The `describe` smoketest now checks the text output, exact single-entity output, `--json` against `--format json`, and the flag conflict. - Docs: the regenerated CLI reference, the cheat sheet, and the CLI agent skill, plus its codex-plugin copy, which must match byte for byte. - Known gap: inside a submodule table, a column whose type is defined in that submodule prints the bare type name, while Types prefixes it with `lib.`. # API and ABI breaking changes None. `spacetime describe ` without `--json` used to be an error, so no existing invocation changes meaning, and JSON output is byte-for-byte unchanged. `--json` combined with an explicit `--format` is now rejected as a conflict, but `--format` is new to this command. # Rollback safety impact n/a # Expected complexity level and risk 2. The diff is large, but most of it is the new, self-contained renderer and its snapshots. The parts that touch existing code are the `StyledWriter` extraction from the migration printer (its snapshots pass untouched) and the shared `Format` enum now used by `sql` and `logs`. # Testing - [x] Insta snapshots for the renderer: whole module (colour and no colour), a single table, a single reducer. - [x] Unit tests pinning each listing to its module section, and submodule reducer names not being double-prefixed. - [x] `describe` smoketest covers text output, exact single-entity output, `--json` vs `--format json`, and the flag conflict. - [ ] Reviewer: run `spacetime describe` against a real module of your own and check the output reads well. --- .../plugins/spacetimedb/skills/cli/SKILL.md | 6 +- crates/cli/src/common_args.rs | 28 + crates/cli/src/subcommands/describe.rs | 364 +++- crates/cli/src/subcommands/logs.rs | 28 +- crates/cli/src/subcommands/repl.rs | 3 +- crates/cli/src/subcommands/sql.rs | 29 +- crates/schema/src/auto_migrate.rs | 18 +- .../src/auto_migrate/termcolor_formatter.rs | 414 ++--- crates/schema/src/describe.rs | 1538 +++++++++++++++++ crates/schema/src/lib.rs | 2 + ...describe__tests__describe_module_ansi.snap | 82 + ...ribe__tests__describe_module_no_color.snap | 82 + ...ibe__tests__describe_reducer_no_color.snap | 5 + ...cribe__tests__describe_table_no_color.snap | 18 + crates/schema/src/styled_writer.rs | 126 ++ crates/smoketests/src/lib.rs | 2 +- crates/smoketests/tests/cluster/describe.rs | 79 +- .../00100-databases/00500-cheat-sheet.md | 3 +- .../00100-cli-reference.md | 12 +- skills/cli/SKILL.md | 6 +- 20 files changed, 2442 insertions(+), 403 deletions(-) create mode 100644 crates/schema/src/describe.rs create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap create mode 100644 crates/schema/src/styled_writer.rs diff --git a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md index d66ee69e330..229e1028656 100644 --- a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md +++ b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md @@ -81,9 +81,11 @@ spacetime logs my-database -f # follow logs spacetime logs my-database -n 100 # up to 100 log lines # Describe schema +# (without --json, output is human-readable text) spacetime describe my-database --json -spacetime describe my-database table users --json -spacetime describe my-database reducer my_reducer --json +spacetime describe my-database tables --json # also reducers, procedures, types +spacetime describe my-database tables users --json +spacetime describe my-database reducers my_reducer --json ``` ### Database Management diff --git a/crates/cli/src/common_args.rs b/crates/cli/src/common_args.rs index 381565d8040..0f1f7aebcb0 100644 --- a/crates/cli/src/common_args.rs +++ b/crates/cli/src/common_args.rs @@ -8,6 +8,25 @@ pub enum ClearMode { Never, // parses as "never" } +#[derive(Clone, Copy, PartialEq)] +pub enum Format { + Text, + Json, +} + +impl clap::ValueEnum for Format { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Text, Self::Json] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])), + Self::Json => Some(clap::builder::PossibleValue::new("json")), + } + } +} + pub fn server() -> Arg { Arg::new("server") .long("server") @@ -30,6 +49,15 @@ pub fn yes() -> Arg { .help("Run non-interactively wherever possible. This will answer \"yes\" to almost all prompts, but will sometimes answer \"no\" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com).") } +/// The `--format` arg, parsed as a [`Format`]. Callers supply their own `.help(...)`. +pub fn format() -> Arg { + Arg::new("format") + .long("format") + .default_value("text") + .required(false) + .value_parser(value_parser!(Format)) +} + pub fn confirmed() -> Arg { Arg::new("confirmed") .required(false) diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index 1f841187428..9373716ae97 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -1,14 +1,25 @@ use crate::api::ClientApi; -use crate::common_args; +use crate::common_args::{self, Format}; use crate::config::Config; use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_with_optional_parts}; +use crate::subcommands::publish::pretty_print_style_from_env; use crate::util::UNSTABLE_WARNING; use crate::util::{database_identity, get_auth_header}; use anyhow::Context; use clap::{Arg, ArgAction, ArgMatches}; -use spacetimedb_lib::db::raw_def::v10::{RawReducerDefV10, RawTableDefV10}; +use spacetimedb_client_api_messages::name::PrettyPrintStyle as EnvStyle; +use spacetimedb_lib::db::raw_def::v10::{RawProcedureDefV10, RawReducerDefV10, RawTableDefV10, RawTypeDefV10}; use spacetimedb_lib::sats; +use spacetimedb_schema::auto_migrate::PrettyPrintStyle; use spacetimedb_schema::def::ModuleDef; +use spacetimedb_schema::describe::{ + all_named_types, describe_module, describe_procedure, describe_procedures, describe_reducer, describe_reducers, + describe_table, describe_tables, describe_type, describe_types, sorted_procedures, sorted_reducers, sorted_tables, + sorted_types, +}; +use std::io::IsTerminal; + +const USAGE: &str = "spacetime describe [database] [entity_type [entity_name]] [--format text|json] [--no-config]"; pub fn cli() -> clap::Command { clap::Command::new("describe") @@ -18,18 +29,15 @@ pub fn cli() -> clap::Command { .arg( Arg::new("describe_parts") .num_args(0..) - .help("Describe arguments: [DATABASE] [ENTITY_TYPE ENTITY_NAME]"), + .help("Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]]"), ) + .arg(common_args::format().help("Output format for the schema")) .arg( Arg::new("json") .long("json") .action(ArgAction::SetTrue) - // make not required() once we have a human readable output - .required(true) - .help( - "Output the schema in JSON format. Currently required; in the future, omitting this will \ - give human-readable output.", - ), + .conflicts_with("format") + .help("Output the schema in JSON format. Shorthand for `--format json`."), ) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) @@ -43,48 +51,77 @@ pub fn cli() -> clap::Command { .after_help("Run `spacetime help describe` for more detailed information.\n") } -#[derive(clap::ValueEnum, Clone, Copy)] +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)] enum EntityType { + Procedure, Reducer, Table, + Type, +} + +/// What to describe: the whole module, every entity of one type, or a single named entity. +#[derive(Debug, PartialEq)] +enum Selection<'a> { + Module, + All(EntityType), + One(EntityType, &'a str), +} + +/// Parses an entity type. The plural is canonical, matching the section headings of the module +/// output, but the singular is accepted too. +fn parse_entity_type(entity_type: &str) -> anyhow::Result { + match entity_type { + "procedures" | "procedure" => Ok(EntityType::Procedure), + "reducers" | "reducer" => Ok(EntityType::Reducer), + "tables" | "table" => Ok(EntityType::Table), + "types" | "type" => Ok(EntityType::Type), + _ => { + anyhow::bail!("Invalid entity_type '{entity_type}'. Expected one of: procedures, reducers, tables, types.") + } + } +} + +/// Parses the describe arguments left over once the database has been resolved. +fn parse_selection(parts: &[String]) -> anyhow::Result> { + match parts { + [] => Ok(Selection::Module), + [entity_type] => Ok(Selection::All(parse_entity_type(entity_type)?)), + [entity_type, entity_name] => Ok(Selection::One(parse_entity_type(entity_type)?, entity_name)), + _ => anyhow::bail!("Invalid describe arguments.\nUsage: {USAGE}"), + } +} + +fn output_format(args: &ArgMatches) -> Format { + if args.get_flag("json") { + Format::Json + } else { + *args.get_one::("format").unwrap() + } +} + +/// Colour only when stdout is a terminal and `NO_COLOR` is unset. +fn text_style() -> PrettyPrintStyle { + if !std::io::stdout().is_terminal() { + return PrettyPrintStyle::NoColor; + } + match pretty_print_style_from_env() { + EnvStyle::AnsiColor => PrettyPrintStyle::AnsiColor, + EnvStyle::NoColor => PrettyPrintStyle::NoColor, + } } pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let json = args.get_flag("json"); + let format = output_format(args); let no_config = args.get_flag("no_config"); let raw_parts: Vec = args .get_many::("describe_parts") .map(|vals| vals.cloned().collect()) .unwrap_or_default(); let config_targets = load_config_db_targets(no_config)?; - let resolved = resolve_database_with_optional_parts( - &raw_parts, - config_targets.as_deref(), - "spacetime describe [database] [entity_type entity_name] --json [--no-config]", - )?; - let entity = match resolved.remaining_args.as_slice() { - [] => None, - [entity_type, entity_name] => { - let entity_type = match entity_type.as_str() { - "reducer" => EntityType::Reducer, - "table" => EntityType::Table, - _ => { - anyhow::bail!( - "Invalid entity_type '{}'. Expected one of: reducer, table.", - entity_type - ) - } - }; - Some((entity_type, entity_name.as_str())) - } - _ => { - anyhow::bail!( - "Invalid describe arguments.\nUsage: spacetime describe [database] [entity_type entity_name] --json [--no-config]" - ); - } - }; + let resolved = resolve_database_with_optional_parts(&raw_parts, config_targets.as_deref(), USAGE)?; + let selection = parse_selection(&resolved.remaining_args)?; let mut config = config; let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); @@ -101,36 +138,237 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let raw = api.module_def().await?; - if json { - fn sats_to_json(v: &T) -> serde_json::Result { - serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(v)) - } - let json = match entity { - // Entity lookups go through the validated `ModuleDef`, which resolves - // canonical names and dot-qualified names from submodules - // (e.g. `lib.my_reducer`). - Some((EntityType::Reducer, reducer_name)) => { - let module_def: ModuleDef = raw.try_into()?; - let (_, reducer) = module_def.reducer_by_name(reducer_name).context("no such reducer")?; - sats_to_json(&RawReducerDefV10::from(reducer.clone()))? + fn sats_to_json(v: &T) -> serde_json::Result { + serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(v)) + } + + // The whole-module JSON is the raw, unvalidated def, exactly as before. + if format == Format::Json && matches!(selection, Selection::Module) { + // TODO: validate the JSON output + println!("{}", sats_to_json(&raw)?); + return Ok(()); + } + + // Entity lookups and text rendering go through the validated `ModuleDef`, which resolves + // canonical names and dot-qualified names from submodules (e.g. `lib.my_reducer`). + let module_def: ModuleDef = raw.try_into()?; + match selection { + Selection::Module => print!("{}", describe_module(&module_def, text_style())), + Selection::All(EntityType::Procedure) => match format { + Format::Json => { + let procedures: Vec = sorted_procedures(&module_def) + .into_iter() + .map(|(_, _, procedure)| procedure.clone().into()) + .collect(); + println!("{}", sats_to_json(&procedures)?) } - Some((EntityType::Table, table_name)) => { - let module_def: ModuleDef = raw.try_into()?; - let (_, _, table) = module_def - .all_tables_with_prefix() + Format::Text => print!("{}", describe_procedures(&module_def, text_style())), + }, + Selection::All(EntityType::Reducer) => match format { + Format::Json => { + let reducers: Vec = sorted_reducers(&module_def) .into_iter() - .find(|(prefix, _, t)| format!("{}{}", prefix, &*t.name) == *table_name) - .context("no such table")?; - sats_to_json(&RawTableDefV10::from(table.clone()))? + .map(|(_, _, reducer)| reducer.clone().into()) + .collect(); + println!("{}", sats_to_json(&reducers)?) } - None => sats_to_json(&raw)?, - }; - - // TODO: validate the JSON output - println!("{json}"); - } else { - // TODO: human-readable API + Format::Text => print!("{}", describe_reducers(&module_def, text_style())), + }, + Selection::All(EntityType::Table) => match format { + Format::Json => { + let tables: Vec = sorted_tables(&module_def) + .into_iter() + .map(|(_, _, table)| table.clone().into()) + .collect(); + println!("{}", sats_to_json(&tables)?) + } + Format::Text => print!("{}", describe_tables(&module_def, text_style())), + }, + Selection::All(EntityType::Type) => match format { + Format::Json => { + let types: Vec = sorted_types(&module_def) + .into_iter() + .map(|named| named.def.clone().into()) + .collect(); + println!("{}", sats_to_json(&types)?) + } + Format::Text => print!("{}", describe_types(&module_def, text_style())), + }, + Selection::One(EntityType::Procedure, procedure_name) => { + let (prefix, owning, procedure) = module_def + .all_procedures_with_prefix() + .into_iter() + .find(|(prefix, _, p)| format!("{prefix}{}", p.name) == *procedure_name) + .context("no such procedure")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawProcedureDefV10::from(procedure.clone()))?), + Format::Text => print!("{}", describe_procedure(&prefix, owning, procedure, text_style())), + } + } + Selection::One(EntityType::Reducer, reducer_name) => { + let (_, reducer, owning) = module_def + .reducer_by_name_with_module(reducer_name) + .context("no such reducer")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawReducerDefV10::from(reducer.clone()))?), + Format::Text => print!("{}", describe_reducer(owning, reducer, text_style())), + } + } + Selection::One(EntityType::Table, table_name) => { + let (prefix, owning, table) = module_def + .all_tables_with_prefix() + .into_iter() + .find(|(prefix, _, t)| format!("{}{}", prefix, &*t.name) == *table_name) + .context("no such table")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawTableDefV10::from(table.clone()))?), + Format::Text => print!("{}", describe_table(&prefix, owning, table, text_style())), + } + } + Selection::One(EntityType::Type, type_name) => { + // Any named type can be looked up, including the row types the listing leaves out. + let named = all_named_types(&module_def) + .into_iter() + .find(|named| named.qualified == *type_name) + .context("no such type")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawTypeDefV10::from(named.def.clone()))?), + Format::Text => print!("{}", describe_type(&named, text_style())), + } + } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + + fn parse_format(args: &[&str]) -> Format { + output_format(&cli().try_get_matches_from(args).unwrap()) + } + + fn parts(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() + } + + #[test] + fn cli_is_well_formed() { + cli().debug_assert(); + } + + #[test] + fn format_defaults_to_text() { + assert!(parse_format(&["describe", "db"]) == Format::Text); + } + + #[test] + fn json_flag_selects_json() { + assert!(parse_format(&["describe", "db", "--json"]) == Format::Json); + } + + #[test] + fn format_json_selects_json() { + assert!(parse_format(&["describe", "db", "--format", "json"]) == Format::Json); + } + + #[test] + fn format_text_aliases_select_text() { + assert!(parse_format(&["describe", "db", "--format", "txt"]) == Format::Text); + assert!(parse_format(&["describe", "db", "--format", "default"]) == Format::Text); + } + + #[test] + fn json_flag_conflicts_with_explicit_format() { + for format in ["text", "json"] { + let err = cli() + .try_get_matches_from(["describe", "db", "--json", "--format", format]) + .err() + .unwrap_or_else(|| panic!("`--json --format {format}` should be rejected")); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + } + + #[test] + fn json_flag_keeps_entity_parts() { + let matches = cli() + .try_get_matches_from(["describe", "db", "tables", "t", "--json"]) + .unwrap(); + let parts: Vec<&String> = matches.get_many::("describe_parts").unwrap().collect(); + assert_eq!(parts, ["db", "tables", "t"]); + assert!(output_format(&matches) == Format::Json); + } + + #[test] + fn no_entity_selects_the_module() { + assert_eq!(parse_selection(&parts(&[])).unwrap(), Selection::Module); + } + + #[test] + fn entity_type_alone_selects_all_of_that_type() { + for (entity_type, expected) in [ + ("procedures", EntityType::Procedure), + ("reducers", EntityType::Reducer), + ("tables", EntityType::Table), + ("types", EntityType::Type), + ] { + assert_eq!( + parse_selection(&parts(&[entity_type])).unwrap(), + Selection::All(expected) + ); + } + } + + #[test] + fn entity_type_and_name_select_one_entity() { + for (entity_type, name, expected) in [ + ("procedures", "lib.count", EntityType::Procedure), + ("reducers", "lib.add", EntityType::Reducer), + ("tables", "person", EntityType::Table), + ("types", "geo.Point", EntityType::Type), + ] { + assert_eq!( + parse_selection(&parts(&[entity_type, name])).unwrap(), + Selection::One(expected, name) + ); + } + } + + #[test] + fn singular_entity_types_are_accepted() { + for (singular, plural) in [ + ("procedure", "procedures"), + ("reducer", "reducers"), + ("table", "tables"), + ("type", "types"), + ] { + assert_eq!( + parse_selection(&parts(&[singular])).unwrap(), + parse_selection(&parts(&[plural])).unwrap() + ); + assert_eq!( + parse_selection(&parts(&[singular, "x"])).unwrap(), + parse_selection(&parts(&[plural, "x"])).unwrap() + ); + } + } + + #[test] + fn unknown_entity_type_is_rejected() { + for bad in [parts(&["Tables"]), parts(&["columns", "name"])] { + let err = parse_selection(&bad).unwrap_err().to_string(); + assert!(err.contains("Invalid entity_type"), "{err}"); + assert!(err.contains("procedures, reducers, tables, types"), "{err}"); + } + } + + #[test] + fn too_many_parts_are_rejected() { + let err = parse_selection(&parts(&["table", "person", "extra"])) + .unwrap_err() + .to_string(); + assert!(err.contains("Invalid describe arguments"), "{err}"); + } +} diff --git a/crates/cli/src/subcommands/logs.rs b/crates/cli/src/subcommands/logs.rs index 57d388fea9e..ca2e15afb71 100644 --- a/crates/cli/src/subcommands/logs.rs +++ b/crates/cli/src/subcommands/logs.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use std::io::{self, Write}; use crate::common_args; +use crate::common_args::Format; use crate::config::Config; use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg}; use crate::util::{add_auth_header_opt, database_identity, get_auth_header}; @@ -40,14 +41,7 @@ pub fn cli() -> clap::Command { .help("A flag indicating whether or not to follow the logs") .long_help("A flag that causes logs to not stop when end of the log file is reached, but rather to wait for additional data to be appended to the input."), ) - .arg( - Arg::new("format") - .long("format") - .default_value("text") - .required(false) - .value_parser(clap::value_parser!(Format)) - .help("Output format for the logs") - ) + .arg(common_args::format().help("Output format for the logs")) .arg( Arg::new("level") .long("level") @@ -166,24 +160,6 @@ struct LogsParams { follow: bool, } -#[derive(Clone, Copy, PartialEq)] -pub enum Format { - Text, - Json, -} - -impl clap::ValueEnum for Format { - fn value_variants<'a>() -> &'a [Self] { - &[Self::Text, Self::Json] - } - fn to_possible_value(&self) -> Option { - match self { - Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])), - Self::Json => Some(clap::builder::PossibleValue::new("json")), - } - } -} - pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); let no_config = args.get_flag("no_config"); diff --git a/crates/cli/src/subcommands/repl.rs b/crates/cli/src/subcommands/repl.rs index 6561475ca92..9487f95c1d2 100644 --- a/crates/cli/src/subcommands/repl.rs +++ b/crates/cli/src/subcommands/repl.rs @@ -1,5 +1,6 @@ use crate::api::{ClientApi, Connection}; -use crate::sql::{run_sql, Format}; +use crate::common_args::Format; +use crate::sql::run_sql; use colored::*; use dirs::home_dir; use std::env::temp_dir; diff --git a/crates/cli/src/subcommands/sql.rs b/crates/cli/src/subcommands/sql.rs index 5b782d7106b..3267b15b3b6 100644 --- a/crates/cli/src/subcommands/sql.rs +++ b/crates/cli/src/subcommands/sql.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use crate::api::{from_json_seed, ClientApi, Connection, SqlStmtResult, StmtStats}; use crate::common_args; +use crate::common_args::Format; use crate::config::Config; use crate::subcommands::db_arg_resolution::{ load_config_db_targets, resolve_database_arg, resolve_optional_database_parts, ResolvedDbArgs, @@ -33,14 +34,7 @@ pub fn cli() -> clap::Command { .arg(common_args::confirmed()) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) - .arg( - Arg::new("format") - .long("format") - .default_value("text") - .required(false) - .value_parser(clap::value_parser!(Format)) - .help("Output format for the SQL results"), - ) + .arg(common_args::format().help("Output format for the SQL results")) .arg(common_args::yes()) .arg( Arg::new("no_config") @@ -50,25 +44,6 @@ pub fn cli() -> clap::Command { ) } -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum Format { - Text, - Json, -} - -impl clap::ValueEnum for Format { - fn value_variants<'a>() -> &'a [Self] { - &[Self::Text, Self::Json] - } - - fn to_possible_value(&self) -> Option { - match self { - Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])), - Self::Json => Some(clap::builder::PossibleValue::new("json")), - } - } -} - pub(crate) async fn parse_req( mut config: Config, args: &ArgMatches, diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 97fff428830..02f8e32283e 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -20,7 +20,7 @@ use spacetimedb_sats::{ raw_identifier::RawIdentifier, AlgebraicType, WithTypespace, }; -use termcolor_formatter::{ColorScheme, TermColorFormatter}; +use termcolor_formatter::TermColorFormatter; use thiserror::Error; mod formatter; mod termcolor_formatter; @@ -69,23 +69,17 @@ impl<'def> MigratePlan<'def> { } pub fn pretty_print(&self, style: PrettyPrintStyle) -> anyhow::Result { - use PrettyPrintStyle::*; match self { MigratePlan::Manual(_) => { anyhow::bail!("Manual migration plans are not yet supported for pretty printing.") } - MigratePlan::Auto(plan) => match style { - NoColor => { - let mut fmt = TermColorFormatter::new(ColorScheme::default(), termcolor::ColorChoice::Never); - format_plan(&mut fmt, plan).map(|_| fmt.to_string()) - } - AnsiColor => { - let mut fmt = TermColorFormatter::new(ColorScheme::default(), termcolor::ColorChoice::AlwaysAnsi); - format_plan(&mut fmt, plan).map(|_| fmt.to_string()) - } + MigratePlan::Auto(plan) => { + let mut fmt = TermColorFormatter::new(style); + format_plan(&mut fmt, plan) + .map(|_| fmt.into_string()) + .map_err(|e| anyhow::anyhow!("Failed to format migration plan: {e}")) } - .map_err(|e| anyhow::anyhow!("Failed to format migration plan: {e}")), } } } diff --git a/crates/schema/src/auto_migrate/termcolor_formatter.rs b/crates/schema/src/auto_migrate/termcolor_formatter.rs index 811c04b1860..3b301181ddd 100644 --- a/crates/schema/src/auto_migrate/termcolor_formatter.rs +++ b/crates/schema/src/auto_migrate/termcolor_formatter.rs @@ -1,12 +1,12 @@ -use std::io::Write; -use std::{fmt, io}; +use std::io; use spacetimedb_lib::{db::raw_def::v9::TableAccess, AlgebraicType}; use spacetimedb_primitives::ColId; use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; -use termcolor::{Buffer, Color, ColorChoice, ColorSpec, WriteColor}; use crate::auto_migrate::formatter::ViewInfo; +use crate::auto_migrate::PrettyPrintStyle; +use crate::styled_writer::StyledWriter; use super::formatter::{ AccessChangeInfo, Action, ColumnChange, ColumnChanges, ConstraintInfo, IndexInfo, MigrationFormatter, NewColumns, @@ -14,124 +14,32 @@ use super::formatter::{ }; use crate::identifier::NamespacedIdentifier; -/// Color scheme for consistent formatting -#[derive(Debug, Clone)] -pub struct ColorScheme { - pub created: Color, - pub removed: Color, - pub changed: Color, - pub header: Color, - pub table_name: Color, - pub column_type: Color, - pub section_header: Color, - pub access: Color, - pub warning: Color, -} - -impl Default for ColorScheme { - fn default() -> Self { - Self { - created: Color::Green, - removed: Color::Red, - changed: Color::Yellow, - header: Color::Blue, - table_name: Color::Cyan, - column_type: Color::Magenta, - section_header: Color::Blue, - access: Color::Green, - warning: Color::Red, - } - } -} +const MIGRATION_INDENT_WIDTH: usize = 4; #[derive(Debug)] pub struct TermColorFormatter { - buffer: Buffer, - colors: ColorScheme, - indent_level: usize, -} - -impl fmt::Display for TermColorFormatter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let buffer_content = std::str::from_utf8(self.buffer.as_slice()).map_err(|_| fmt::Error)?; - write!(f, "{buffer_content}") - } + out: StyledWriter, } impl TermColorFormatter { - pub fn new(colors: ColorScheme, choice: ColorChoice) -> Self { + pub fn new(style: PrettyPrintStyle) -> Self { Self { - buffer: if choice == ColorChoice::Never { - Buffer::no_color() - } else { - Buffer::ansi() - }, - colors, - indent_level: 0, + out: StyledWriter::new(style, MIGRATION_INDENT_WIDTH), } } - fn write_indent(&mut self) -> io::Result<()> { - let indent = " ".repeat(self.indent_level); - self.buffer.write_all(indent.as_bytes()) - } - - fn write_line(&mut self, text: impl AsRef) -> io::Result<()> { - self.write_indent()?; - self.buffer.write_all(text.as_ref().as_bytes())?; - self.buffer.write_all(b"\n") - } - - fn write_colored(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { - let mut spec = ColorSpec::new(); - if let Some(c) = color { - spec.set_fg(Some(c)); - } - if bold { - spec.set_bold(true); - } - self.buffer.set_color(&spec)?; - self.buffer.write_all(text.as_bytes())?; - self.buffer.reset()?; - Ok(()) - } - - fn write_colored_line(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { - self.write_indent()?; - self.write_colored(text, color, bold)?; - self.buffer.write_all(b"\n") - } - - fn write_with_background(&mut self, text: &str, bg: Color, bold: bool) -> io::Result<()> { - let mut spec = ColorSpec::new(); - spec.set_bg(Some(bg)); - if bold { - spec.set_bold(true); - } - self.buffer.set_color(&spec)?; - self.buffer.write_all(text.as_bytes())?; - self.buffer.reset()?; - Ok(()) + pub fn into_string(self) -> String { + self.out.into_string() } fn write_bullet(&mut self, text: &str) -> io::Result<()> { - self.write_line(format!("• {text}")) + self.out.write_line(format!("• {text}")) } fn write_action_prefix(&mut self, action: &Action) -> io::Result<()> { - self.write_indent()?; - self.buffer.write_all("▸ ".to_string().as_bytes())?; - action.write_with_color(&mut self.buffer, &self.colors) - } - - fn indent(&mut self) { - self.indent_level += 1; - } - - fn dedent(&mut self) { - if self.indent_level > 0 { - self.indent_level -= 1; - } + self.out.write_indent()?; + self.out.write_plain("▸ ")?; + action.write_with_color(&mut self.out) } fn format_type_name(&self, ty: &AlgebraicType) -> String { @@ -140,7 +48,7 @@ impl TermColorFormatter { fn write_type_name(&mut self, ty: &AlgebraicType) -> io::Result<()> { let s = self.format_type_name(ty); - self.write_colored(&s, Some(self.colors.column_type), false) + self.out.write_colored(&s, Some(self.out.colors().column_type), false) } fn format_access(&self, access: TableAccess) -> &'static str { @@ -152,164 +60,178 @@ impl TermColorFormatter { fn write_access(&mut self, access: TableAccess) -> io::Result<()> { let s = self.format_access(access); - self.write_colored(s, Some(self.colors.access), false) + self.out.write_colored(s, Some(self.out.colors().access), false) } } impl MigrationFormatter for TermColorFormatter { fn format_header(&mut self) -> io::Result<()> { let line = "━".repeat(60); - self.write_line(&line)?; - self.write_colored_line("Database Migration Plan", Some(self.colors.header), true)?; - self.write_line(&line)?; - self.write_line("") + self.out.write_line(&line)?; + self.out + .write_colored_line("Database Migration Plan", Some(self.out.colors().header), true)?; + self.out.write_line(&line)?; + self.out.write_line("") } fn format_add_table(&mut self, table: &TableInfo) -> io::Result<()> { // Table header - self.write_indent()?; - self.buffer.write_all("▸ ".to_string().as_bytes())?; - Action::Created.write_with_color(&mut self.buffer, &self.colors)?; + self.out.write_indent()?; + self.out.write_plain("▸ ")?; + Action::Created.write_with_color(&mut self.out)?; let kind = if table.is_system { "system" } else { "user" }; - self.buffer.write_all(format!(" {kind} table: ").as_bytes())?; - self.write_colored(&table.name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b" (")?; + self.out.write_plain(&format!(" {kind} table: "))?; + self.out + .write_colored(&table.name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(" (")?; self.write_access(table.access)?; - self.buffer.write_all(b")\n")?; + self.out.write_plain(")\n")?; - self.indent(); + self.out.indent(); if !table.columns.is_empty() { - self.write_colored_line("Columns:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Columns:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for col in &table.columns { - self.write_indent()?; - self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("• {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain("\n")?; } - self.dedent(); + self.out.dedent(); } if !table.constraints.is_empty() { - self.write_colored_line("Unique constraints:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Unique constraints:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for c in &table.constraints { let cols = c.columns.iter().map(|x| x.to_string()).collect::>().join(", "); self.write_bullet(&format!("{} on [{}]", c.name, cols))?; } - self.dedent(); + self.out.dedent(); } if !table.indexes.is_empty() { - self.write_colored_line("Indexes:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Indexes:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for i in &table.indexes { let cols = i.columns.iter().map(|x| x.to_string()).collect::>().join(", "); self.write_bullet(&format!("{} on [{}]", i.name, cols))?; } - self.dedent(); + self.out.dedent(); } if !table.sequences.is_empty() { - self.write_colored_line("Auto-increment constraints:", Some(self.colors.section_header), true)?; - self.indent(); + self.out.write_colored_line( + "Auto-increment constraints:", + Some(self.out.colors().section_header), + true, + )?; + self.out.indent(); for s in &table.sequences { self.write_bullet(&format!("{} on {}", s.name, s.column_name))?; } - self.dedent(); + self.out.dedent(); } if let Some(s) = &table.schedule { - self.write_colored_line("Schedule:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Schedule:", Some(self.out.colors().section_header), true)?; + self.out.indent(); self.write_bullet(&format!("Calls {}: {}", s.function_kind, s.function_name))?; - self.dedent(); + self.out.dedent(); } - self.dedent(); - self.write_line("") + self.out.dedent(); + self.out.write_line("") } fn format_remove_table(&mut self, table_name: &NamespacedIdentifier) -> io::Result<()> { self.write_action_prefix(&Action::Removed)?; - self.buffer.write_all(b" table: ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; - self.write_line("") + self.out.write_plain(" table: ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; + self.out.write_line("") } fn format_view(&mut self, view: &ViewInfo, action: Action) -> io::Result<()> { - self.write_indent()?; - self.buffer.write_all("▸ ".to_string().as_bytes())?; + self.out.write_indent()?; + self.out.write_plain("▸ ")?; self.write_action_prefix(&action)?; - self.buffer.write_all(if view.is_anonymous { - b" anonymous view: " + self.out.write_plain(if view.is_anonymous { + " anonymous view: " } else { - b" view: " + " view: " })?; - self.write_colored(&view.name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out + .write_colored(&view.name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; - self.indent(); + self.out.indent(); if !view.params.is_empty() { - self.write_colored_line("Parameters:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Parameters:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for col in &view.params { - self.write_indent()?; - self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("• {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain("\n")?; } - self.dedent(); + self.out.dedent(); } if !view.columns.is_empty() { - self.write_colored_line("Columns:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Columns:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for col in &view.columns { - self.write_indent()?; - self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("• {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain("\n")?; } - self.dedent(); + self.out.dedent(); } - self.dedent(); - self.write_line("") + self.out.dedent(); + self.out.write_line("") } fn format_constraint(&mut self, c: &ConstraintInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; let cols = c.columns.iter().map(|x| x.to_string()).collect::>().join(", "); - self.buffer - .write_all(format!(" unique constraint {} on [{}] of table ", c.name, cols).as_bytes())?; - self.write_colored(&c.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out + .write_plain(&format!(" unique constraint {} on [{}] of table ", c.name, cols))?; + self.out + .write_colored(&c.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_index(&mut self, i: &IndexInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; let cols = i.columns.iter().map(|x| x.to_string()).collect::>().join(", "); - self.buffer - .write_all(format!(" index {} on [{}] of table ", i.name, cols).as_bytes())?; - self.write_colored(&i.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out + .write_plain(&format!(" index {} on [{}] of table ", i.name, cols))?; + self.out + .write_colored(&i.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_sequence(&mut self, s: &SequenceInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; - self.buffer.write_all( - format!( - " auto-increment constraint {} on column {} of table ", - s.name, s.column_name - ) - .as_bytes(), - )?; - self.write_colored(&s.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out.write_plain(&format!( + " auto-increment constraint {} on column {} of table ", + s.name, s.column_name + ))?; + self.out + .write_colored(&s.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_change_access(&mut self, a: &AccessChangeInfo) -> io::Result<()> { @@ -318,11 +240,13 @@ impl MigrationFormatter for TermColorFormatter { TableAccess::Public => "private → public", }; self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" access for table ")?; - self.write_colored(&a.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b" (")?; - self.write_colored(direction, Some(self.colors.access), false)?; - self.buffer.write_all(b")\n") + self.out.write_plain(" access for table ")?; + self.out + .write_colored(&a.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(" (")?; + self.out + .write_colored(direction, Some(self.out.colors().access), false)?; + self.out.write_plain(")\n") } fn format_change_primary_key( @@ -338,90 +262,95 @@ impl MigrationFormatter for TermColorFormatter { (None, None) => return Ok(()), }; self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" primary key on table ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(format!(" ({description})\n").as_bytes()) + self.out.write_plain(" primary key on table ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(&format!(" ({description})\n")) } fn format_schedule(&mut self, s: &ScheduleInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; - self.buffer.write_all(b" schedule for table ")?; - self.write_colored(&s.table_name, Some(self.colors.table_name), true)?; - self.buffer - .write_all(format!(" calling {} {}\n", s.function_kind, s.function_name).as_bytes()) + self.out.write_plain(" schedule for table ")?; + self.out + .write_colored(&s.table_name, Some(self.out.colors().table_name), true)?; + self.out + .write_plain(&format!(" calling {} {}\n", s.function_kind, s.function_name)) } fn format_rls(&mut self, r: &RlsInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; - self.buffer.write_all(b" row level security policy:\n")?; - self.indent(); - self.write_indent()?; - self.buffer.write_all(b"`")?; - self.write_colored(&r.policy, Some(self.colors.section_header), false)?; - self.buffer.write_all(b"`\n")?; - self.dedent(); + self.out.write_plain(" row level security policy:\n")?; + self.out.indent(); + self.out.write_indent()?; + self.out.write_plain("`")?; + self.out + .write_colored(&r.policy, Some(self.out.colors().section_header), false)?; + self.out.write_plain("`\n")?; + self.out.dedent(); Ok(()) } fn format_change_columns(&mut self, cs: &ColumnChanges) -> io::Result<()> { self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" columns for table ")?; - self.write_colored(&cs.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain(" columns for table ")?; + self.out + .write_colored(&cs.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; - self.indent(); + self.out.indent(); for ch in &cs.changes { - self.write_indent()?; + self.out.write_indent()?; match ch { ColumnChange::Renamed { old_name, new_name } => { - self.buffer - .write_all(format!("~ Renamed: {old_name} → {new_name}\n").as_bytes())?; + self.out.write_plain(&format!("~ Renamed: {old_name} → {new_name}\n"))?; } ColumnChange::TypeChanged { name, old_type, new_type, } => { - self.buffer.write_all(format!("~ Modified: {name} (").as_bytes())?; + self.out.write_plain(&format!("~ Modified: {name} ("))?; self.write_type_name(old_type)?; - self.buffer.write_all(" → ".to_string().as_bytes())?; + self.out.write_plain(" → ")?; self.write_type_name(new_type)?; - self.buffer.write_all(b")\n")?; + self.out.write_plain(")\n")?; } } } - self.dedent(); + self.out.dedent(); Ok(()) } fn format_add_columns(&mut self, nc: &NewColumns) -> io::Result<()> { let plural = if nc.columns.len() > 1 { "s" } else { "" }; self.write_action_prefix(&Action::Created)?; - self.buffer.write_all(format!(" column{plural} in table ").as_bytes())?; - self.write_colored(&nc.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain(&format!(" column{plural} in table "))?; + self.out + .write_colored(&nc.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; - self.indent(); + self.out.indent(); for col in &nc.columns { let default = col .default_value .as_ref() .map(|v| format!(" (default: {v:#?})")) .unwrap_or_default(); - self.write_indent()?; - self.buffer.write_all(format!("+ {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("+ {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(format!("{default}\n").as_bytes())?; + self.out.write_plain(&format!("{default}\n"))?; } - self.dedent(); + self.out.dedent(); Ok(()) } fn format_change_table_accessor_name(&mut self, table_name: &str) -> io::Result<()> { self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" table accessor name for ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out.write_plain(" table accessor name for ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_change_column_accessor_name( @@ -430,21 +359,23 @@ impl MigrationFormatter for TermColorFormatter { col_name: &str, ) -> io::Result<()> { self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" column accessor name for ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b".")?; - self.write_colored(col_name, Some(self.colors.column_type), true)?; - self.buffer.write_all(b"\n") + self.out.write_plain(" column accessor name for ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(".")?; + self.out + .write_colored(col_name, Some(self.out.colors().column_type), true)?; + self.out.write_plain("\n") } fn format_disconnect_warning(&mut self) -> io::Result<()> { - self.write_indent()?; - self.write_with_background( + self.out.write_indent()?; + self.out.write_with_background( "!!! Warning: All clients will be disconnected due to breaking schema changes", - self.colors.warning, + self.out.colors().warning, true, )?; - self.buffer.write_all(b"\n") + self.out.write_plain("\n") } fn format_event_table_reschema(&mut self, table_name: &NamespacedIdentifier) -> io::Result<()> { @@ -452,30 +383,27 @@ impl MigrationFormatter for TermColorFormatter { // so for now we're just printing the table name. self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" schema of event table ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain(" schema of event table ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; Ok(()) } } trait ActionColorExt { - fn write_with_color(&self, buffer: &mut Buffer, colors: &ColorScheme) -> io::Result<()>; + fn write_with_color(&self, w: &mut StyledWriter) -> io::Result<()>; } impl ActionColorExt for Action { - fn write_with_color(&self, buffer: &mut Buffer, colors: &ColorScheme) -> io::Result<()> { + fn write_with_color(&self, w: &mut StyledWriter) -> io::Result<()> { + let colors = w.colors(); let (text, color) = match self { Action::Created => ("Created", colors.created), Action::Removed => ("Removed", colors.removed), Action::Changed => ("Changed", colors.changed), }; - let mut spec = ColorSpec::new(); - spec.set_fg(Some(color)).set_bold(true); - buffer.set_color(&spec)?; - buffer.write_all(text.as_bytes())?; - buffer.reset()?; - Ok(()) + w.write_colored(text, Some(color), true) } } diff --git a/crates/schema/src/describe.rs b/crates/schema/src/describe.rs new file mode 100644 index 00000000000..ba14db1ecbf --- /dev/null +++ b/crates/schema/src/describe.rs @@ -0,0 +1,1538 @@ +//! Human-readable rendering of a [`ModuleDef`], for `spacetime describe`. +//! +//! Types are spelled in a language-neutral way: primitives as `fmt_algebraic_type` spells them +//! (`U64`, `Bool`), containers as `Array`, `Option` and `Result`, the unit type as +//! `()`, special types by name (`Timestamp`, `Identity`, ...), and named types by their scoped +//! name joined with `.` (`geo.shapes.Point`). +//! +//! [`describe_module`] renders a whole module as sections (Tables, Views, Reducers, Procedures, +//! HTTP routes, Types, then Row-level security), omitting empty sections. [`describe_table`], +//! [`describe_reducer`], [`describe_procedure`] and [`describe_type`] render a single entity as it +//! appears in its section, and [`describe_tables`], [`describe_reducers`], [`describe_procedures`] +//! and [`describe_types`] render the contents of a whole section. All of them use two-space +//! indentation, end in a single newline, and never leave trailing whitespace. Column +//! widths are worked out from the uncoloured text, so the `AnsiColor` and `NoColor` styles align +//! identically. + +use std::collections::HashSet; +use std::fmt; +use std::io; + +use convert_case::{Case, Casing}; +use itertools::Itertools; +use spacetimedb_lib::db::raw_def::v10::MethodOrAny; +use spacetimedb_lib::db::raw_def::v9::{Lifecycle, TableAccess}; +use spacetimedb_lib::http::Method as HttpMethod; +use spacetimedb_primitives::ColId; +use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; +use spacetimedb_sats::satn::Satn; +use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, WithTypespace}; + +use crate::auto_migrate::PrettyPrintStyle; +use crate::def::{ + ColumnDef, HttpRouteDef, IndexAlgorithm, ModuleDef, ProcedureDef, ReducerDef, TableDef, TypeDef, ViewDef, +}; +use crate::identifier::NamespacePath; +use crate::styled_writer::StyledWriter; +use crate::type_for_generate::{AlgebraicTypeDef, AlgebraicTypeUse, ProductTypeDef}; + +/// Displays the name of an [`AlgebraicTypeUse`], resolving refs through the owning [`ModuleDef`]. +pub struct TypeName<'a> { + def: &'a ModuleDef, + ty: &'a AlgebraicTypeUse, +} + +/// The language-neutral name of `ty`, e.g. `Option>`. +pub fn type_name<'a>(def: &'a ModuleDef, ty: &'a AlgebraicTypeUse) -> TypeName<'a> { + TypeName { def, ty } +} + +impl fmt::Display for TypeName<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // No wildcard arm: a new `AlgebraicTypeUse` variant must be given a spelling here. + match self.ty { + AlgebraicTypeUse::Ref(r) => { + if let Some((name, _)) = self.def.type_def_from_ref(*r) { + write!(f, "{}", name.name_segments().format(".")) + } else if let Some(ty) = self.def.typespace().get(*r) { + write!(f, "{}", fmt_algebraic_type(ty)) + } else { + write!(f, "{r}") + } + } + AlgebraicTypeUse::Array(elem) => write!(f, "Array<{}>", type_name(self.def, elem)), + AlgebraicTypeUse::Option(inner) => write!(f, "Option<{}>", type_name(self.def, inner)), + AlgebraicTypeUse::Result { ok_ty, err_ty } => write!( + f, + "Result<{}, {}>", + type_name(self.def, ok_ty), + type_name(self.def, err_ty) + ), + AlgebraicTypeUse::ScheduleAt => f.write_str("ScheduleAt"), + AlgebraicTypeUse::Identity => f.write_str("Identity"), + AlgebraicTypeUse::ConnectionId => f.write_str("ConnectionId"), + AlgebraicTypeUse::Timestamp => f.write_str("Timestamp"), + AlgebraicTypeUse::TimeDuration => f.write_str("TimeDuration"), + AlgebraicTypeUse::Uuid => f.write_str("Uuid"), + AlgebraicTypeUse::Unit => f.write_str("()"), + AlgebraicTypeUse::Never => f.write_str("Never"), + AlgebraicTypeUse::String => f.write_str("String"), + AlgebraicTypeUse::Primitive(prim) => write!(f, "{}", fmt_algebraic_type(&prim.algebraic_type())), + } + } +} + +/// Displays a parameter list as `name: Type, name: Type`, without surrounding parentheses. +pub struct ParamList<'a> { + def: &'a ModuleDef, + params: &'a ProductTypeDef, +} + +/// The parameter list of a reducer, procedure or view, e.g. `name: String, count: U32`. +pub fn param_list<'a>(def: &'a ModuleDef, params: &'a ProductTypeDef) -> ParamList<'a> { + ParamList { def, params } +} + +impl fmt::Display for ParamList<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, (name, ty)) in self.params.elements.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{name}: {}", type_name(self.def, ty))?; + } + Ok(()) + } +} + +const INDENT_WIDTH: usize = 2; + +const BUFFER_WRITE: &str = "writing to an in-memory buffer cannot fail"; + +/// Renders the whole module as human-readable text, one section per kind of entity. +/// +/// The sections are Tables, Views, Reducers, Procedures, HTTP routes, Types and Row-level +/// security, in that order. Empty sections are omitted, so an empty module renders as `""`. +pub fn describe_module(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_module(&mut w, def).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single table's block, as it appears under `Tables` in [`describe_module`] but +/// unindented. +/// +/// `prefix` and `owning` are the namespace path and owning module returned alongside `table` by +/// [`ModuleDef::all_tables_with_prefix`]. +pub fn describe_table(prefix: &NamespacePath, owning: &ModuleDef, table: &TableDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_table_block(&mut w, prefix, owning, table).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single reducer's row, as it appears under `Reducers` in [`describe_module`] but +/// unindented. +/// +/// `owning` is the module returned alongside `reducer` by +/// [`ModuleDef::reducer_by_name_with_module`]. The reducer's name is already qualified with its +/// namespace path, so no prefix is needed. +pub fn describe_reducer(owning: &ModuleDef, reducer: &ReducerDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_reducer_row(&mut w, owning, reducer).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single procedure's row, as it appears under `Procedures` in [`describe_module`] but +/// unindented. +/// +/// `prefix` and `owning` are the namespace path and owning module returned alongside `procedure` by +/// [`ModuleDef::all_procedures_with_prefix`]. +pub fn describe_procedure( + prefix: &NamespacePath, + owning: &ModuleDef, + procedure: &ProcedureDef, + style: PrettyPrintStyle, +) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_procedure_row(&mut w, prefix, owning, procedure).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single named type's row, as it appears under `Types` in [`describe_module`] but +/// unindented. +/// +/// `named` comes from [`sorted_types`] or [`all_named_types`]. A type the `Types` section leaves +/// out, such as a table's row type, renders the same way. +pub fn describe_type(named: &NamedType<'_>, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_type_row(&mut w, named).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders every table in the module, including those in submodules, each as [`describe_table`] +/// renders it, separated by blank lines and in the order [`describe_module`] lists them. +/// +/// A module with no tables renders as `""`. +pub fn describe_tables(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_table_blocks(&mut w, &sorted_tables(def)).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders every reducer in the module, including those in submodules, one row each as +/// [`describe_reducer`] renders it, in the order [`describe_module`] lists them. +/// +/// A module with no reducers renders as `""`. +pub fn describe_reducers(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for (_, owning, reducer) in sorted_reducers(def) { + write_reducer_row(&mut w, owning, reducer).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Renders every procedure in the module, including those in submodules, one row each as +/// [`describe_procedure`] renders it, in the order [`describe_module`] lists them. +/// +/// A module with no procedures renders as `""`. +pub fn describe_procedures(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for (prefix, owning, procedure) in sorted_procedures(def) { + write_procedure_row(&mut w, &prefix, owning, procedure).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Renders the named types [`describe_module`] lists under `Types`, one row each as +/// [`describe_type`] renders it, in the same order. +/// +/// A module with no such types renders as `""`. +pub fn describe_types(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for named in sorted_types(def) { + write_type_row(&mut w, &named).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Every table in the module, including those in submodules, sorted by qualified name: the order +/// in which [`describe_module`] and [`describe_tables`] list them. +pub fn sorted_tables(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &TableDef)> { + def.all_tables_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, table)| format!("{prefix}{}", table.name)) + .collect_vec() +} + +/// Every reducer in the module, including those in submodules, sorted by qualified name: the order +/// in which [`describe_module`] and [`describe_reducers`] list them. +pub fn sorted_reducers(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &ReducerDef)> { + // A reducer's name is already qualified with its namespace path. + def.all_reducers_with_prefix() + .into_iter() + .sorted_by_cached_key(|(_, _, reducer)| reducer.name.to_string()) + .collect_vec() +} + +/// Every procedure in the module, including those in submodules, sorted by qualified name: the +/// order in which [`describe_module`] and [`describe_procedures`] list them. +pub fn sorted_procedures(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &ProcedureDef)> { + def.all_procedures_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, procedure)| format!("{prefix}{}", procedure.name)) + .collect_vec() +} + +/// The named types [`describe_module`] and [`describe_types`] list, in the order they list them. +/// +/// These are the types reachable from a column, or from a view, reducer or procedure's parameters +/// or return type, including the types those refer to in turn. Table row types are left out, +/// because each table's own block already shows its columns. [`all_named_types`] has every type. +pub fn sorted_types(def: &ModuleDef) -> Vec> { + let tables = def.all_tables_with_prefix(); + let views = def.all_views_with_prefix(); + let reducers = def.all_reducers_with_prefix(); + let procedures = def.all_procedures_with_prefix(); + + let column_roots = tables.iter().flat_map(|&(ref prefix, owning, table)| { + table + .columns + .iter() + .map(move |col| (prefix.clone(), owning, &col.ty_for_generate)) + }); + let view_roots = views.iter().flat_map(|&(ref prefix, owning, view)| { + param_roots(prefix.clone(), owning, &view.params_for_generate).chain([( + prefix.clone(), + owning, + &view.return_type_for_generate, + )]) + }); + let reducer_roots = reducers + .iter() + .flat_map(|&(ref prefix, owning, reducer)| param_roots(prefix.clone(), owning, &reducer.params_for_generate)); + let procedure_roots = procedures.iter().flat_map(|&(ref prefix, owning, procedure)| { + param_roots(prefix.clone(), owning, &procedure.params_for_generate).chain([( + prefix.clone(), + owning, + &procedure.return_type_for_generate, + )]) + }); + reachable_types( + column_roots + .chain(view_roots) + .chain(reducer_roots) + .chain(procedure_roots), + ) +} + +/// Every named type in the module and its submodules, sorted by qualified name. +/// +/// Unlike [`sorted_types`], this includes table row types and types nothing refers to. +pub fn all_named_types(def: &ModuleDef) -> Vec> { + fn collect<'a>(prefix: &NamespacePath, owning: &'a ModuleDef, out: &mut Vec>) { + out.extend(owning.types().map(|type_def| NamedType::new(prefix, owning, type_def))); + for (namespace, submodule) in owning.submodules() { + collect(&prefix.child(namespace.clone()), submodule, out); + } + } + + let mut types = Vec::new(); + collect(&NamespacePath::root(), def, &mut types); + types.sort_by(|a, b| a.qualified.cmp(&b.qualified)); + types +} + +fn write_module(w: &mut StyledWriter, def: &ModuleDef) -> io::Result<()> { + let tables = sorted_tables(def); + let views = def + .all_views_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, view)| format!("{prefix}{}", view.name)) + .collect_vec(); + let reducers = sorted_reducers(def); + let procedures = sorted_procedures(def); + // Only the root module's routes are served, and route order matters, so keep declaration order. + let http_routes = def.http_routes(); + let types = sorted_types(def); + let row_level_security = def.row_level_security().map(|rls| &*rls.sql).sorted().collect_vec(); + + let mut wrote_section = false; + if !tables.is_empty() { + write_section_header(w, &mut wrote_section, "Tables")?; + write_tables(w, &tables)?; + } + if !views.is_empty() { + write_section_header(w, &mut wrote_section, "Views")?; + write_rows(w, &views, |w, (prefix, owning, view)| { + write_view_row(w, prefix, owning, view) + })?; + } + if !reducers.is_empty() { + write_section_header(w, &mut wrote_section, "Reducers")?; + write_rows(w, &reducers, |w, (_, owning, reducer)| { + write_reducer_row(w, owning, reducer) + })?; + } + if !procedures.is_empty() { + write_section_header(w, &mut wrote_section, "Procedures")?; + write_rows(w, &procedures, |w, (prefix, owning, procedure)| { + write_procedure_row(w, prefix, owning, procedure) + })?; + } + if !http_routes.is_empty() { + write_section_header(w, &mut wrote_section, "HTTP routes")?; + write_rows(w, http_routes, write_http_route_row)?; + } + if !types.is_empty() { + write_section_header(w, &mut wrote_section, "Types")?; + write_rows(w, &types, write_type_row)?; + } + if !row_level_security.is_empty() { + write_section_header(w, &mut wrote_section, "Row-level security")?; + write_rows(w, &row_level_security, |w, sql| w.write_line(sql))?; + } + Ok(()) +} + +/// The types of a function's parameters, as roots for [`reachable_types`]. +/// +/// The parameter list's own product type is deliberately not a root: some module languages +/// register it as a named type (`Init` for an `init` reducer), and it is not a type anyone uses. +fn param_roots<'a>( + prefix: NamespacePath, + owning: &'a ModuleDef, + params: &'a ProductTypeDef, +) -> impl Iterator + 'a { + params.elements.iter().map(move |(_, ty)| (prefix.clone(), owning, ty)) +} + +/// Writes one row per item, indented one level below the section heading. +fn write_rows( + w: &mut StyledWriter, + items: &[T], + mut write_row: impl FnMut(&mut StyledWriter, &T) -> io::Result<()>, +) -> io::Result<()> { + w.indent(); + for item in items { + write_row(w, item)?; + } + w.dedent(); + Ok(()) +} + +/// Writes a function's row: `name(param: Type, ...) -> Return [tag] [tag]`. +/// +/// The return type is omitted when `ret` is `None`, and nothing follows the closing parenthesis +/// or return type when there are no tags, so the row never ends in whitespace. +fn write_function_row( + w: &mut StyledWriter, + qualified: &str, + owning: &ModuleDef, + params: &ProductTypeDef, + ret: Option, + tags: &[String], +) -> io::Result<()> { + w.write_indent()?; + w.write_colored(qualified, Some(w.colors().table_name), true)?; + w.write_plain("(")?; + write_params(w, owning, params)?; + w.write_plain(")")?; + if let Some(ret) = ret { + w.write_plain(" -> ")?; + w.write_colored(&ret, Some(w.colors().column_type), false)?; + } + if !tags.is_empty() { + w.write_plain(" ")?; + for (i, tag) in tags.iter().enumerate() { + if i > 0 { + w.write_plain(" ")?; + } + w.write_colored(tag, Some(w.colors().access), false)?; + } + } + w.write_plain("\n") +} + +/// Writes a parameter list as `name: Type, name: Type`, with each type coloured. +fn write_params(w: &mut StyledWriter, owning: &ModuleDef, params: &ProductTypeDef) -> io::Result<()> { + for (i, (name, ty)) in params.elements.iter().enumerate() { + if i > 0 { + w.write_plain(", ")?; + } + w.write_plain(&format!("{name}: "))?; + w.write_colored(&type_name(owning, ty).to_string(), Some(w.colors().column_type), false)?; + } + Ok(()) +} + +fn write_view_row(w: &mut StyledWriter, prefix: &NamespacePath, owning: &ModuleDef, view: &ViewDef) -> io::Result<()> { + let ret = type_name(owning, &view.return_type_for_generate).to_string(); + let tags = [ + Some(if view.is_public { "[public]" } else { "[private]" }), + view.is_anonymous.then_some("[anonymous]"), + ] + .into_iter() + .flatten() + .map(str::to_owned) + .collect_vec(); + write_function_row( + w, + &format!("{prefix}{}", view.name), + owning, + &view.params_for_generate, + Some(ret), + &tags, + ) +} + +fn write_reducer_row(w: &mut StyledWriter, owning: &ModuleDef, reducer: &ReducerDef) -> io::Result<()> { + // Validation currently forces this to `()`, so it is always omitted in practice. + let ret = (!reducer.ok_return_type.is_unit()).then(|| fmt_algebraic_type(&reducer.ok_return_type).to_string()); + let tags = [ + reducer + .lifecycle + .map(|lifecycle| format!("[lifecycle: {}]", lifecycle_name(lifecycle))), + reducer.visibility.is_private().then(|| "[private]".to_owned()), + ] + .into_iter() + .flatten() + .collect_vec(); + write_function_row( + w, + reducer.name.as_ref(), + owning, + &reducer.params_for_generate, + ret, + &tags, + ) +} + +fn write_procedure_row( + w: &mut StyledWriter, + prefix: &NamespacePath, + owning: &ModuleDef, + procedure: &ProcedureDef, +) -> io::Result<()> { + let ret = (!matches!(procedure.return_type_for_generate, AlgebraicTypeUse::Unit)) + .then(|| type_name(owning, &procedure.return_type_for_generate).to_string()); + let tags = procedure + .visibility + .is_private() + .then(|| "[private]".to_owned()) + .into_iter() + .collect_vec(); + write_function_row( + w, + &format!("{prefix}{}", procedure.name), + owning, + &procedure.params_for_generate, + ret, + &tags, + ) +} + +/// The name of a lifecycle as it appears in a reducer's `[lifecycle: ...]` tag. +fn lifecycle_name(lifecycle: Lifecycle) -> String { + match lifecycle { + Lifecycle::Init => "init".to_owned(), + Lifecycle::OnConnect => "client_connected".to_owned(), + Lifecycle::OnDisconnect => "client_disconnected".to_owned(), + other => format!("{other:?}").to_case(Case::Snake), + } +} + +/// Writes an HTTP route's row: `METHOD path → handler`. +fn write_http_route_row(w: &mut StyledWriter, route: &HttpRouteDef) -> io::Result<()> { + // An empty path is valid, but would otherwise be invisible. + let path = if route.path.is_empty() { "\"\"" } else { &route.path }; + w.write_indent()?; + w.write_plain(&format!("{} {path} → ", http_method_name(&route.method)))?; + w.write_colored(&route.handler_name.to_string(), Some(w.colors().table_name), true)?; + w.write_plain("\n") +} + +/// The HTTP method in upper case, `ANY` for a route that matches any method, or an extension +/// method's name as written. +fn http_method_name(method: &MethodOrAny) -> String { + match method { + MethodOrAny::Any => "ANY".to_owned(), + MethodOrAny::Method(method) => match method { + HttpMethod::Get => "GET".to_owned(), + HttpMethod::Head => "HEAD".to_owned(), + HttpMethod::Post => "POST".to_owned(), + HttpMethod::Put => "PUT".to_owned(), + HttpMethod::Delete => "DELETE".to_owned(), + HttpMethod::Connect => "CONNECT".to_owned(), + HttpMethod::Options => "OPTIONS".to_owned(), + HttpMethod::Trace => "TRACE".to_owned(), + HttpMethod::Patch => "PATCH".to_owned(), + HttpMethod::Extension(name) => name.clone(), + }, + other => format!("{other:?}").to_uppercase(), + } +} + +/// Writes a section heading, preceded by a blank line unless it is the first section. +fn write_section_header(w: &mut StyledWriter, wrote_section: &mut bool, title: &str) -> io::Result<()> { + if std::mem::replace(wrote_section, true) { + // Not `write_line("")`, which would leave the indent behind as trailing whitespace. + w.write_plain("\n")?; + } + w.write_colored_line(title, Some(w.colors().section_header), true) +} + +/// Writes a subsection label such as `Columns:` on its own line. +fn write_subsection_label(w: &mut StyledWriter, label: &str) -> io::Result<()> { + w.write_colored_line(label, Some(w.colors().section_header), true) +} + +fn write_tables(w: &mut StyledWriter, tables: &[(NamespacePath, &ModuleDef, &TableDef)]) -> io::Result<()> { + w.indent(); + write_table_blocks(w, tables)?; + w.dedent(); + Ok(()) +} + +/// Writes each table's block at the writer's current indent, with a blank line between blocks. +fn write_table_blocks(w: &mut StyledWriter, tables: &[(NamespacePath, &ModuleDef, &TableDef)]) -> io::Result<()> { + for (i, (prefix, owning, table)) in tables.iter().enumerate() { + if i > 0 { + w.write_plain("\n")?; + } + write_table_block(w, prefix, owning, table)?; + } + Ok(()) +} + +/// A column's row under `Columns:`, as uncoloured text so widths can be measured. +struct ColumnRow { + name: String, + ty: String, + flags: String, +} + +fn column_row(owning: &ModuleDef, table: &TableDef, col: &ColumnDef) -> ColumnRow { + let is_primary_key = table.primary_key == Some(col.col_id); + let is_unique = !is_primary_key + && table.constraints.values().any(|constraint| { + constraint + .data + .unique_columns() + .is_some_and(|cols| cols.as_singleton() == Some(col.col_id)) + }); + let is_auto_inc = table.sequences.values().any(|seq| seq.column == col.col_id); + let default = col.default_value.as_ref().map(|value| { + let value = WithTypespace::new(owning.typespace(), &col.ty).with_value(value); + format!("default: {}", value.to_satn()) + }); + + let flags = [ + is_primary_key.then(|| "primary key".to_owned()), + is_unique.then(|| "unique".to_owned()), + is_auto_inc.then(|| "auto-increment".to_owned()), + default, + ] + .into_iter() + .flatten() + .join(", "); + + ColumnRow { + name: col.name.to_string(), + ty: type_name(owning, &col.ty_for_generate).to_string(), + flags, + } +} + +/// The name of column `col` of `table`, or its position if there is no such column. +fn column_name(table: &TableDef, col: ColId) -> String { + table + .get_column(col) + .map_or_else(|| col.idx().to_string(), |col| col.name.to_string()) +} + +/// The width of `text` when padded with `format!("{: usize { + text.chars().count() +} + +/// Writes `table`'s heading and subsections, starting at the writer's current indent. +fn write_table_block( + w: &mut StyledWriter, + prefix: &NamespacePath, + owning: &ModuleDef, + table: &TableDef, +) -> io::Result<()> { + let access = match table.table_access { + TableAccess::Public => "public", + TableAccess::Private => "private", + }; + let access = if table.is_event { + format!("{access}, event") + } else { + access.to_owned() + }; + w.write_indent()?; + w.write_colored(&format!("{prefix}{}", table.name), Some(w.colors().table_name), true)?; + w.write_plain(" (")?; + w.write_colored(&access, Some(w.colors().access), false)?; + w.write_plain(")\n")?; + + w.indent(); + + write_subsection_label(w, "Columns:")?; + let rows = table + .columns + .iter() + .sorted_by_key(|col| col.col_id) + .map(|col| column_row(owning, table, col)) + .collect_vec(); + let name_width = rows.iter().map(|row| text_width(&row.name)).max().unwrap_or(0) + 2; + let type_width = rows.iter().map(|row| text_width(&row.ty)).max().unwrap_or(0) + 2; + w.indent(); + for row in &rows { + w.write_indent()?; + w.write_plain(&format!("{: 1)?; + Some((&constraint.name, cols)) + }) + .sorted_by_key(|(name, _)| *name) + .collect_vec(); + if !multi_column_uniques.is_empty() { + write_subsection_label(w, "Unique constraints:")?; + w.indent(); + for (_, cols) in multi_column_uniques { + let cols = cols.iter().map(|col| column_name(table, col)).join(", "); + w.write_line(format!("({cols})"))?; + } + w.dedent(); + } + + if !table.indexes.is_empty() { + let indexes = table + .indexes + .values() + .map(|index| { + let algorithm = match &index.algorithm { + IndexAlgorithm::BTree(_) => "btree", + IndexAlgorithm::Hash(_) => "hash", + IndexAlgorithm::Direct(_) => "direct", + }; + let cols = index + .algorithm + .columns() + .iter() + .map(|col| column_name(table, col)) + .join(", "); + (format!("{prefix}{}", index.name), format!("{algorithm} ({cols})")) + }) + .sorted() + .collect_vec(); + let name_width = indexes.iter().map(|(name, _)| text_width(name)).max().unwrap_or(0) + 2; + write_subsection_label(w, "Indexes:")?; + w.indent(); + for (name, algorithm) in indexes { + w.write_line(format!("{name: { + /// The type's scoped name joined with `.`, prefixed with the namespace path of its owning + /// module (`lib.geo.Point`). + pub qualified: String, + /// The module that owns the type, against which its refs resolve. + pub owning: &'a ModuleDef, + /// The type's definition, whose `ty` refers into `owning`'s typespace. + pub def: &'a TypeDef, +} + +impl<'a> NamedType<'a> { + fn new(prefix: &NamespacePath, owning: &'a ModuleDef, def: &'a TypeDef) -> Self { + let qualified = format!("{prefix}{}", def.accessor_name.name_segments().format(".")); + Self { qualified, owning, def } + } +} + +/// The named types reachable from `roots`, including the types they refer to in turn, sorted by +/// qualified name. +/// +/// Each root is a type use together with the namespace path and owning module it appears in. +/// Types that are a table's row type are skipped, because those already appear under Tables. +fn reachable_types<'a>( + roots: impl IntoIterator, +) -> Vec> { + let mut work = roots.into_iter().collect_vec(); + let mut visited = HashSet::new(); + let mut found = Vec::new(); + + while let Some((prefix, owning, ty)) = work.pop() { + match ty { + AlgebraicTypeUse::Array(inner) | AlgebraicTypeUse::Option(inner) => work.push((prefix, owning, inner)), + AlgebraicTypeUse::Result { ok_ty, err_ty } => { + work.push((prefix.clone(), owning, ok_ty)); + work.push((prefix, owning, err_ty)); + } + AlgebraicTypeUse::Ref(r) => { + if !visited.insert((prefix.clone(), *r)) { + continue; + } + if let Some((_, type_def)) = owning.type_def_from_ref(*r) + && !owning.tables().any(|table| table.product_type_ref == *r) + { + found.push(NamedType::new(&prefix, owning, type_def)); + } + match owning.typespace_for_generate().get(*r) { + Some(AlgebraicTypeDef::Product(product)) => { + work.extend(product.elements.iter().map(|(_, ty)| (prefix.clone(), owning, ty))) + } + Some(AlgebraicTypeDef::Sum(sum)) => { + work.extend(sum.variants.iter().map(|(_, ty)| (prefix.clone(), owning, ty))) + } + Some(AlgebraicTypeDef::PlainEnum(_)) | None => {} + } + } + AlgebraicTypeUse::ScheduleAt + | AlgebraicTypeUse::Identity + | AlgebraicTypeUse::ConnectionId + | AlgebraicTypeUse::Timestamp + | AlgebraicTypeUse::TimeDuration + | AlgebraicTypeUse::Uuid + | AlgebraicTypeUse::Unit + | AlgebraicTypeUse::Never + | AlgebraicTypeUse::String + | AlgebraicTypeUse::Primitive(_) => {} + } + } + + found.sort_by(|a, b| a.qualified.cmp(&b.qualified)); + found +} + +/// Writes a named type's row: `Name = body`. +fn write_type_row(w: &mut StyledWriter, named: &NamedType<'_>) -> io::Result<()> { + w.write_indent()?; + w.write_colored(&named.qualified, Some(w.colors().table_name), true)?; + w.write_plain(" = ")?; + write_type_body(w, named.owning, named.def.ty)?; + w.write_plain("\n") +} + +/// The canonical names of the fields or variants of the product or sum type `r`. +/// +/// `typespace_for_generate` keeps the names from the module source (`imageUrl`), while the +/// validated typespace has the canonical ones (`image_url`). Falls back to `source_names` if the +/// two disagree about the type's shape. +fn element_names<'a>(owning: &'a ModuleDef, r: AlgebraicTypeRef, source_names: Vec<&'a str>) -> Vec<&'a str> { + let canonical: Option> = match owning.typespace().get(r) { + Some(AlgebraicType::Product(product)) => product.elements.iter().map(|e| e.name().map(|n| &**n)).collect(), + Some(AlgebraicType::Sum(sum)) => sum.variants.iter().map(|v| v.name().map(|n| &**n)).collect(), + _ => None, + }; + canonical + .filter(|names| names.len() == source_names.len()) + .unwrap_or(source_names) +} + +/// Writes the right-hand side of a `Name = ...` row in the Types section. +fn write_type_body(w: &mut StyledWriter, owning: &ModuleDef, r: AlgebraicTypeRef) -> io::Result<()> { + match owning.typespace_for_generate().get(r) { + Some(AlgebraicTypeDef::Product(product)) => { + if product.elements.is_empty() { + return w.write_plain("{}"); + } + let names = element_names(owning, r, product.elements.iter().map(|(n, _)| &**n).collect()); + w.write_plain("{ ")?; + for (i, (name, (_, ty))) in names.iter().zip(&product.elements).enumerate() { + if i > 0 { + w.write_plain(", ")?; + } + w.write_plain(&format!("{name}: "))?; + w.write_colored(&type_name(owning, ty).to_string(), Some(w.colors().column_type), false)?; + } + w.write_plain(" }") + } + Some(AlgebraicTypeDef::Sum(sum)) => { + let names = element_names(owning, r, sum.variants.iter().map(|(n, _)| &**n).collect()); + for (i, (name, (_, ty))) in names.iter().zip(&sum.variants).enumerate() { + if i > 0 { + w.write_plain(" | ")?; + } + w.write_plain(name)?; + if !matches!(ty, AlgebraicTypeUse::Unit) { + w.write_plain("(")?; + w.write_colored(&type_name(owning, ty).to_string(), Some(w.colors().column_type), false)?; + w.write_plain(")")?; + } + } + Ok(()) + } + Some(AlgebraicTypeDef::PlainEnum(plain)) => { + let names = element_names(owning, r, plain.variants.iter().map(|n| &**n).collect()); + w.write_plain(&names.join(" | ")) + } + // Validation gives every named type a definition for generation, but if one is missing, + // show its structure rather than nothing. + None => match owning.typespace().get(r) { + Some(ty) => w.write_colored(&fmt_algebraic_type(ty).to_string(), Some(w.colors().column_type), false), + None => w.write_plain(&r.to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identifier::Identifier; + use spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, FunctionVisibility as RawFunctionVisibility, RawModuleDefV10Builder, + RawModuleDefV10Section, RawSubmoduleV10, + }; + use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; + use spacetimedb_lib::{ProductType, ScheduleAt}; + use spacetimedb_sats::layout::PrimitiveType; + use spacetimedb_sats::{AlgebraicValue, SumValue}; + use std::sync::Arc; + + fn create_module_def_v10(build_module: impl Fn(&mut RawModuleDefV10Builder)) -> ModuleDef { + let mut builder = RawModuleDefV10Builder::new(); + build_module(&mut builder); + builder + .finish() + .try_into() + .expect("new_def should be a valid database definition") + } + + fn empty_module() -> ModuleDef { + create_module_def_v10(|_| {}) + } + + fn add_thumbnail(builder: &mut RawModuleDefV10Builder) -> AlgebraicTypeRef { + let thumbnail = AlgebraicType::product([("url", AlgebraicType::String), ("width", AlgebraicType::U32)]); + builder.add_algebraic_type([], "Thumbnail", thumbnail, true) + } + + /// The type of parameter `param` of reducer `reducer`, as it appears in `params_for_generate`. + fn param_ty<'a>(def: &'a ModuleDef, reducer: &str, param: &str) -> &'a AlgebraicTypeUse { + let reducer = def.reducer(reducer).expect("reducer should exist"); + reducer + .params_for_generate + .elements + .iter() + .find(|(name, _)| &**name == param) + .map(|(_, ty)| ty) + .expect("param should exist") + } + + fn name_of(def: &ModuleDef, ty: &AlgebraicTypeUse) -> String { + type_name(def, ty).to_string() + } + + #[test] + fn primitives() { + let def = empty_module(); + let cases = [ + (PrimitiveType::Bool, "Bool"), + (PrimitiveType::I8, "I8"), + (PrimitiveType::U8, "U8"), + (PrimitiveType::I16, "I16"), + (PrimitiveType::U16, "U16"), + (PrimitiveType::I32, "I32"), + (PrimitiveType::U32, "U32"), + (PrimitiveType::I64, "I64"), + (PrimitiveType::U64, "U64"), + (PrimitiveType::I128, "I128"), + (PrimitiveType::U128, "U128"), + (PrimitiveType::I256, "I256"), + (PrimitiveType::U256, "U256"), + (PrimitiveType::F32, "F32"), + (PrimitiveType::F64, "F64"), + ]; + for (prim, expected) in cases { + assert_eq!(name_of(&def, &AlgebraicTypeUse::Primitive(prim)), expected); + } + } + + #[test] + fn special_and_builtin_types() { + let def = empty_module(); + let cases = [ + (AlgebraicTypeUse::String, "String"), + (AlgebraicTypeUse::Unit, "()"), + (AlgebraicTypeUse::Never, "Never"), + (AlgebraicTypeUse::ScheduleAt, "ScheduleAt"), + (AlgebraicTypeUse::Identity, "Identity"), + (AlgebraicTypeUse::ConnectionId, "ConnectionId"), + (AlgebraicTypeUse::Timestamp, "Timestamp"), + (AlgebraicTypeUse::TimeDuration, "TimeDuration"), + (AlgebraicTypeUse::Uuid, "Uuid"), + ]; + for (ty, expected) in cases { + assert_eq!(name_of(&def, &ty), expected); + } + } + + #[test] + fn containers() { + let def = empty_module(); + let u32_ty = Arc::new(AlgebraicTypeUse::Primitive(PrimitiveType::U32)); + let string_ty = Arc::new(AlgebraicTypeUse::String); + + assert_eq!(name_of(&def, &AlgebraicTypeUse::Array(u32_ty.clone())), "Array"); + assert_eq!( + name_of(&def, &AlgebraicTypeUse::Option(string_ty.clone())), + "Option" + ); + assert_eq!( + name_of( + &def, + &AlgebraicTypeUse::Result { + ok_ty: u32_ty, + err_ty: string_ty, + } + ), + "Result" + ); + } + + #[test] + fn named_ref() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + builder.add_reducer( + "set_thumb", + ProductType::from([("thumb", AlgebraicType::Ref(thumbnail))]), + ); + }); + assert_eq!(name_of(&def, param_ty(&def, "set_thumb", "thumb")), "Thumbnail"); + } + + #[test] + fn nested_containers_with_ref() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + let thumbs = AlgebraicType::option(AlgebraicType::array(AlgebraicType::Ref(thumbnail))); + builder.add_reducer("set_thumbs", ProductType::from([("thumbs", thumbs)])); + }); + assert_eq!( + name_of(&def, param_ty(&def, "set_thumbs", "thumbs")), + "Option>" + ); + } + + #[test] + fn scoped_ref() { + let build = |policy: Option| { + create_module_def_v10(move |builder| { + if let Some(policy) = policy { + builder.set_case_conversion_policy(policy); + } + let point = AlgebraicType::product([("x", AlgebraicType::F32), ("y", AlgebraicType::F32)]); + let point = builder.add_algebraic_type(["geo".into(), "shapes".into()], "Point", point, true); + builder.add_reducer("move_to", ProductType::from([("point", AlgebraicType::Ref(point))])); + }) + }; + + // The default policy converts type names and their scope segments to PascalCase. + let def = build(None); + assert_eq!(name_of(&def, param_ty(&def, "move_to", "point")), "Geo.Shapes.Point"); + + // With no conversion, the source names are kept, which also shows the separator is `.`. + let def = build(Some(CaseConversionPolicy::None)); + assert_eq!(name_of(&def, param_ty(&def, "move_to", "point")), "geo.shapes.Point"); + } + + #[test] + fn result_through_module() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + let outcome = AlgebraicType::result(AlgebraicType::U32, AlgebraicType::Ref(thumbnail)); + builder.add_reducer("try_thumb", ProductType::from([("outcome", outcome)])); + }); + assert_eq!( + name_of(&def, param_ty(&def, "try_thumb", "outcome")), + "Result" + ); + } + + #[test] + fn unresolvable_ref_falls_back() { + // Validation gives every ref in a `ModuleDef` a name in its refmap, so the fallback from a + // missing name to `fmt_algebraic_type` of the resolved type can't be reached through a + // validated module. A ref that is out of range for the typespace is the reachable case: + // it must print the raw ref rather than panic. + let def = empty_module(); + let ty = AlgebraicTypeUse::Ref(AlgebraicTypeRef(u32::MAX)); + assert_eq!(name_of(&def, &ty), "&4294967295"); + } + + #[test] + fn param_list() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + builder.add_reducer( + "update", + ProductType::from([ + ("name", AlgebraicType::String), + ("count", AlgebraicType::U32), + ( + "thumbs", + AlgebraicType::option(AlgebraicType::array(AlgebraicType::Ref(thumbnail))), + ), + ]), + ); + builder.add_reducer("tick", ProductType::unit()); + }); + + let update = def.reducer("update").expect("reducer should exist"); + assert_eq!( + super::param_list(&def, &update.params_for_generate).to_string(), + "name: String, count: U32, thumbs: Option>" + ); + + let tick = def.reducer("tick").expect("reducer should exist"); + assert_eq!(super::param_list(&def, &tick.params_for_generate).to_string(), ""); + } + + /// A module exercising every section of the describe output. + fn describe_fixture() -> ModuleDef { + let mut builder = RawModuleDefV10Builder::new(); + + // A camelCase field, to check the canonical `image_url` is shown. + let thumbnail = builder.add_algebraic_type( + [], + "Thumbnail", + AlgebraicType::product([ + ("imageUrl", AlgebraicType::String), + ("width", AlgebraicType::U32), + ("height", AlgebraicType::U32), + ]), + true, + ); + // Reachable only through `Shape`. + let size = builder.add_algebraic_type( + [], + "Size", + AlgebraicType::product([("width", AlgebraicType::F32), ("height", AlgebraicType::F32)]), + true, + ); + let shape = builder.add_algebraic_type( + [], + "Shape", + AlgebraicType::sum([ + ("Point", AlgebraicType::unit()), + ("Circle", AlgebraicType::F32), + ("Rect", AlgebraicType::Ref(size)), + ]), + true, + ); + let color = builder.add_algebraic_type( + [], + "Color", + AlgebraicType::simple_enum(["Red", "Green", "Blue"].into_iter()), + true, + ); + // Declared but never used, so it must not be listed. + builder.add_algebraic_type([], "Unused", AlgebraicType::product([("x", AlgebraicType::U8)]), true); + // Reachable only through reducer `move_player`. + let direction = builder.add_algebraic_type( + [], + "Direction", + AlgebraicType::simple_enum(["North", "South", "East", "West"].into_iter()), + true, + ); + // Reachable only through procedure `player_stats`'s return type. + let stats = builder.add_algebraic_type( + [], + "Stats", + AlgebraicType::product([("wins", AlgebraicType::U32), ("losses", AlgebraicType::U32)]), + true, + ); + let schedule_at = builder.add_type::(); + + let player = builder + .build_table_with_new_type( + "player", + ProductType::from([ + ("id", AlgebraicType::U64), + ("name", AlgebraicType::String), + ("rank", AlgebraicType::U32), + ("nickname", AlgebraicType::option(AlgebraicType::String)), + ("joined_at", AlgebraicType::timestamp()), + ("avatar", AlgebraicType::Ref(thumbnail)), + ("color", AlgebraicType::Ref(color)), + ("level", AlgebraicType::U32), + ]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index(btree(0), "player_id", "id") + .with_index(hash(1), "player_name", "name") + .with_unique_constraint(2) + .with_index(direct(2), "player_rank", "rank") + .with_default_column_value(6, AlgebraicValue::Sum(SumValue::new(0, ()))) + .with_default_column_value(7, AlgebraicValue::U32(1)) + .finish(); + + builder + .build_table_with_new_type( + "match_result", + ProductType::from([ + ("player_id", AlgebraicType::U64), + ("round", AlgebraicType::U32), + ("scores", AlgebraicType::array(AlgebraicType::U32)), + ("shape", AlgebraicType::Ref(shape)), + ]), + true, + ) + .with_access(TableAccess::Private) + .with_unique_constraint([0, 1]) + .with_index(btree([0, 1]), "match_result_player_round", "player_round") + .finish(); + + builder + .build_table_with_new_type( + "player_joined", + ProductType::from([("player_id", AlgebraicType::U64), ("name", AlgebraicType::String)]), + true, + ) + .with_event(true) + .finish(); + + let reminder = builder + .build_table_with_new_type( + "reminder", + ProductType::from([ + ("scheduled_id", AlgebraicType::U64), + ("scheduled_at", schedule_at), + ("message", AlgebraicType::String), + ]), + true, + ) + .with_access(TableAccess::Private) + .with_auto_inc_primary_key(0) + .with_index(btree(0), "reminder_scheduled_id", "scheduled_id") + .finish(); + builder.add_procedure( + "send_reminder", + ProductType::from([("job", AlgebraicType::Ref(reminder))]), + AlgebraicType::unit(), + ); + builder.add_schedule("reminder", 1, "send_reminder"); + + builder.add_lifecycle_reducer(Lifecycle::Init, "init", ProductType::unit()); + builder.add_reducer( + "move_player", + ProductType::from([ + ("player_id", AlgebraicType::U64), + ("direction", AlgebraicType::Ref(direction)), + ]), + ); + // Made private below, since the builder only makes lifecycle reducers private. + builder.add_reducer("reset_ranks", ProductType::unit()); + builder.add_procedure( + "player_stats", + ProductType::from([("player_id", AlgebraicType::U64)]), + AlgebraicType::option(AlgebraicType::Ref(stats)), + ); + builder.add_view( + "players_above_rank", + 0, + true, + false, + ProductType::from([("min_rank", AlgebraicType::U32)]), + AlgebraicType::array(AlgebraicType::Ref(player)), + ); + builder.add_view( + "top_player", + 0, + true, + true, + ProductType::unit(), + AlgebraicType::option(AlgebraicType::Ref(player)), + ); + builder.add_http_handler("webhook"); + builder.add_http_handler("health"); + builder.add_http_route("webhook", MethodOrAny::Method(HttpMethod::Post), "/webhook"); + // Declared after `/webhook`, so the output shows declaration order is kept. + builder.add_http_route("health", MethodOrAny::Any, "/health"); + builder.add_row_level_security("SELECT * FROM player WHERE rank > 0"); + // Declared second but sorts first. + builder.add_row_level_security("SELECT * FROM match_result WHERE round > 0"); + + let mut lib = RawModuleDefV10Builder::new(); + lib.build_table_with_new_type( + "session", + ProductType::from([("id", AlgebraicType::U64), ("owner", AlgebraicType::identity())]), + true, + ) + .with_access(TableAccess::Private) + .with_unique_constraint(0) + .with_primary_key(0) + .with_index(btree(0), "session_id", "id") + .finish(); + // A submodule reducer, whose name must be shown qualified exactly once. + lib.add_reducer("end_session", ProductType::from([("id", AlgebraicType::U64)])); + // A submodule procedure, whose name must be shown qualified. + lib.add_procedure("session_count", ProductType::unit(), AlgebraicType::U32); + + let mut raw = builder.finish(); + for section in &mut raw.sections { + if let RawModuleDefV10Section::Reducers(reducers) = section { + for reducer in reducers.iter_mut().filter(|r| &*r.source_name == "reset_ranks") { + reducer.visibility = RawFunctionVisibility::Private; + } + } + } + raw.sections + .push(RawModuleDefV10Section::Submodules(vec![RawSubmoduleV10 { + namespace: "lib".into(), + module: lib.finish(), + }])); + raw.try_into() + .expect("the describe fixture should be a valid module definition") + } + + fn table_with_prefix<'a>(def: &'a ModuleDef, name: &str) -> (NamespacePath, &'a ModuleDef, &'a TableDef) { + def.all_tables_with_prefix() + .into_iter() + .find(|(prefix, _, table)| format!("{prefix}{}", table.name) == name) + .expect("table should exist") + } + + #[test] + fn describe_module_no_color() { + let def = describe_fixture(); + insta::assert_snapshot!( + "describe_module_no_color", + describe_module(&def, PrettyPrintStyle::NoColor) + ); + } + + #[test] + fn describe_module_ansi() { + let def = describe_fixture(); + insta::assert_snapshot!( + "describe_module_ansi", + describe_module(&def, PrettyPrintStyle::AnsiColor) + ); + } + + #[test] + fn describe_table_no_color() { + let def = describe_fixture(); + let (prefix, owning, table) = table_with_prefix(&def, "player"); + insta::assert_snapshot!( + "describe_table_no_color", + describe_table(&prefix, owning, table, PrettyPrintStyle::NoColor) + ); + } + + #[test] + fn describe_reducer_no_color() { + let def = describe_fixture(); + let (_, reducer, owning) = def.reducer_by_name_with_module("init").expect("reducer should exist"); + insta::assert_snapshot!( + "describe_reducer_no_color", + describe_reducer(owning, reducer, PrettyPrintStyle::NoColor) + ); + } + + #[test] + fn describe_reducer_qualifies_submodule_names() { + let def = describe_fixture(); + let (_, reducer, owning) = def + .reducer_by_name_with_module("lib.end_session") + .expect("reducer should exist"); + assert_eq!( + describe_reducer(owning, reducer, PrettyPrintStyle::NoColor), + "lib.end_session(id: U64)\n" + ); + } + + /// The body of section `title` in [`describe_module`] output, dedented back to column 0. + fn module_section(module: &str, title: &str) -> String { + let mut body = module + .lines() + .skip_while(|line| *line != title) + .skip(1) + .take_while(|line| line.is_empty() || line.starts_with(" ")) + .collect_vec(); + // The blank line separating this section from the next one isn't part of it. + while body.last().is_some_and(|line| line.is_empty()) { + body.pop(); + } + body.iter() + .map(|line| format!("{}\n", line.strip_prefix(" ").unwrap_or(line))) + .collect() + } + + #[test] + fn describe_tables_matches_the_module_tables_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let tables = describe_tables(&def, PrettyPrintStyle::NoColor); + assert_eq!(tables, module_section(&module, "Tables")); + // Submodule tables are included, under their qualified names. + assert!(tables.starts_with("lib.session (private)\n"), "{tables}"); + } + + #[test] + fn describe_reducers_matches_the_module_reducers_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let reducers = describe_reducers(&def, PrettyPrintStyle::NoColor); + assert_eq!( + reducers, + "init() [lifecycle: init] [private]\n\ + lib.end_session(id: U64)\n\ + move_player(player_id: U64, direction: Direction)\n\ + reset_ranks() [private]\n" + ); + assert_eq!(reducers, module_section(&module, "Reducers")); + } + + #[test] + fn describe_procedure_qualifies_submodule_names() { + let def = describe_fixture(); + let (prefix, owning, procedure) = def + .all_procedures_with_prefix() + .into_iter() + .find(|(prefix, _, procedure)| format!("{prefix}{}", procedure.name) == "lib.session_count") + .expect("procedure should exist"); + assert_eq!( + describe_procedure(&prefix, owning, procedure, PrettyPrintStyle::NoColor), + "lib.session_count() -> U32\n" + ); + } + + #[test] + fn describe_procedures_matches_the_module_procedures_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let procedures = describe_procedures(&def, PrettyPrintStyle::NoColor); + assert_eq!( + procedures, + "lib.session_count() -> U32\n\ + player_stats(player_id: U64) -> Option\n\ + send_reminder(job: Reminder) [private]\n" + ); + assert_eq!(procedures, module_section(&module, "Procedures")); + } + + #[test] + fn describe_types_matches_the_module_types_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let types = describe_types(&def, PrettyPrintStyle::NoColor); + assert_eq!(types, module_section(&module, "Types")); + assert!(types.starts_with("Color = red | green | blue\n"), "{types}"); + } + + #[test] + fn every_listed_type_can_be_found_by_its_listed_name() { + let def = describe_fixture(); + let all = all_named_types(&def); + for listed in sorted_types(&def) { + let found = all + .iter() + .find(|named| named.qualified == listed.qualified) + .unwrap_or_else(|| panic!("{} should be found", listed.qualified)); + assert!( + std::ptr::eq(found.def, listed.def), + "{} found the wrong type", + listed.qualified + ); + } + } + + #[test] + fn all_named_types_include_row_types_and_unused_types() { + let def = describe_fixture(); + let all = all_named_types(&def); + let find = |qualified: &str| { + all.iter() + .find(|named| named.qualified == qualified) + .unwrap_or_else(|| panic!("{qualified} should be found")) + }; + assert_eq!( + describe_type(find("Unused"), PrettyPrintStyle::NoColor), + "Unused = { x: U8 }\n" + ); + + // A submodule's row type is found under its qualified name, and only that name. + let (_, lib, session) = table_with_prefix(&def, "lib.session"); + let (name, _) = lib + .type_def_from_ref(session.product_type_ref) + .expect("the row type should be named"); + let unqualified = name.name_segments().format(".").to_string(); + assert_eq!( + describe_type(find(&format!("lib.{unqualified}")), PrettyPrintStyle::NoColor), + format!("lib.{unqualified} = {{ id: U64, owner: Identity }}\n") + ); + assert!(all.iter().all(|named| named.qualified != unqualified)); + } + + #[test] + fn listings_of_an_empty_module_are_empty() { + let def = empty_module(); + assert_eq!(describe_tables(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_reducers(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_procedures(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_types(&def, PrettyPrintStyle::NoColor), ""); + } + + #[test] + fn function_signatures_are_type_roots() { + let def = create_module_def_v10(|builder| { + let direction = builder.add_algebraic_type( + [], + "Direction", + AlgebraicType::simple_enum(["North", "South"].into_iter()), + true, + ); + let params = ProductType::from([("direction", AlgebraicType::Ref(direction))]); + // Some module languages register a reducer's parameter list as a named type. It is + // never referred to, so it must not be listed. + builder.add_algebraic_type([], "Walk", AlgebraicType::Product(params.clone()), true); + builder.add_reducer("walk", params); + }); + let text = describe_module(&def, PrettyPrintStyle::NoColor); + assert!(text.contains("\nTypes\n Direction = north | south\n"), "{text}"); + assert!(!text.contains("Walk ="), "{text}"); + } + + #[test] + fn describe_empty_module() { + assert_eq!(describe_module(&empty_module(), PrettyPrintStyle::NoColor), ""); + } + + #[test] + fn no_trailing_whitespace() { + let text = describe_module(&describe_fixture(), PrettyPrintStyle::NoColor); + for (i, line) in text.lines().enumerate() { + assert_eq!( + line, + line.trim_end(), + "line {} has trailing whitespace: {line:?}", + i + 1 + ); + } + assert!(text.ends_with('\n'), "output should end with a newline"); + assert!(!text.ends_with("\n\n"), "output should end with a single newline"); + assert!( + !text.contains("\n\n\n"), + "output should have at most one blank line in a row" + ); + } + + #[test] + fn reachable_types_skip_row_types_and_follow_refs() { + let def = describe_fixture(); + let qualified = |ty: &AlgebraicTypeUse| { + reachable_types([(NamespacePath::root(), &def, ty)]) + .into_iter() + .map(|named| named.qualified) + .collect_vec() + }; + + let (_, _, reminder) = table_with_prefix(&def, "reminder"); + assert!(qualified(&AlgebraicTypeUse::Ref(reminder.product_type_ref)).is_empty()); + + let (_, _, match_result) = table_with_prefix(&def, "match_result"); + let shape = &match_result + .get_column_by_name(&Identifier::for_test("shape")) + .expect("column should exist") + .ty_for_generate; + assert_eq!(qualified(shape), ["Shape", "Size"]); + } +} diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index aa703e37b2d..07588ab48bf 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -4,10 +4,12 @@ pub mod auto_migrate; pub mod def; +pub mod describe; pub mod error; pub mod identifier; pub mod reducer_name; pub mod relation; pub mod schema; +mod styled_writer; pub mod table_name; pub mod type_for_generate; diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap new file mode 100644 index 00000000000..a3aa71a8791 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap @@ -0,0 +1,82 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_module(&def, PrettyPrintStyle::AnsiColor)" +--- +Tables + lib.session (private) + Columns: + id U64 primary key + owner Identity + Indexes: + lib.session_id_idx_btree btree (id) + + match_result (private) + Columns: + player_id U64 + round U32 + scores Array + shape Shape + Unique constraints: + (player_id, round) + Indexes: + match_result_player_id_round_idx_btree btree (player_id, round) + + player (public) + Columns: + id U64 primary key, auto-increment + name String + rank U32 unique + nickname Option + joined_at Timestamp + avatar Thumbnail + color Color default: (red = ()) + level U32 default: 1 + Indexes: + player_id_idx_btree btree (id) + player_name_idx_hash hash (name) + player_rank_idx_direct direct (rank) + + player_joined (public, event) + Columns: + player_id U64 + name String + + reminder (private) + Columns: + scheduled_id U64 primary key, auto-increment + scheduled_at ScheduleAt + message String + Indexes: + reminder_scheduled_id_idx_btree btree (scheduled_id) + Schedule: calls procedure send_reminder + +Views + players_above_rank(min_rank: U32) -> Array [public] + top_player() -> Option [public] [anonymous] + +Reducers + init() [lifecycle: init] [private] + lib.end_session(id: U64) + move_player(player_id: U64, direction: Direction) + reset_ranks() [private] + +Procedures + lib.session_count() -> U32 + player_stats(player_id: U64) -> Option + send_reminder(job: Reminder) [private] + +HTTP routes + POST /webhook → webhook + ANY /health → health + +Types + Color = red | green | blue + Direction = north | south | east | west + Shape = point | circle(F32) | rect(Size) + Size = { width: F32, height: F32 } + Stats = { wins: U32, losses: U32 } + Thumbnail = { image_url: String, width: U32, height: U32 } + +Row-level security + SELECT * FROM match_result WHERE round > 0 + SELECT * FROM player WHERE rank > 0 diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap new file mode 100644 index 00000000000..fb62bb1dde4 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap @@ -0,0 +1,82 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_module(&def, PrettyPrintStyle::NoColor)" +--- +Tables + lib.session (private) + Columns: + id U64 primary key + owner Identity + Indexes: + lib.session_id_idx_btree btree (id) + + match_result (private) + Columns: + player_id U64 + round U32 + scores Array + shape Shape + Unique constraints: + (player_id, round) + Indexes: + match_result_player_id_round_idx_btree btree (player_id, round) + + player (public) + Columns: + id U64 primary key, auto-increment + name String + rank U32 unique + nickname Option + joined_at Timestamp + avatar Thumbnail + color Color default: (red = ()) + level U32 default: 1 + Indexes: + player_id_idx_btree btree (id) + player_name_idx_hash hash (name) + player_rank_idx_direct direct (rank) + + player_joined (public, event) + Columns: + player_id U64 + name String + + reminder (private) + Columns: + scheduled_id U64 primary key, auto-increment + scheduled_at ScheduleAt + message String + Indexes: + reminder_scheduled_id_idx_btree btree (scheduled_id) + Schedule: calls procedure send_reminder + +Views + players_above_rank(min_rank: U32) -> Array [public] + top_player() -> Option [public] [anonymous] + +Reducers + init() [lifecycle: init] [private] + lib.end_session(id: U64) + move_player(player_id: U64, direction: Direction) + reset_ranks() [private] + +Procedures + lib.session_count() -> U32 + player_stats(player_id: U64) -> Option + send_reminder(job: Reminder) [private] + +HTTP routes + POST /webhook → webhook + ANY /health → health + +Types + Color = red | green | blue + Direction = north | south | east | west + Shape = point | circle(F32) | rect(Size) + Size = { width: F32, height: F32 } + Stats = { wins: U32, losses: U32 } + Thumbnail = { image_url: String, width: U32, height: U32 } + +Row-level security + SELECT * FROM match_result WHERE round > 0 + SELECT * FROM player WHERE rank > 0 diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap new file mode 100644 index 00000000000..e562c77294b --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap @@ -0,0 +1,5 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_reducer(owning, reducer, PrettyPrintStyle::NoColor)" +--- +init() [lifecycle: init] [private] diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap new file mode 100644 index 00000000000..31197b50759 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap @@ -0,0 +1,18 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_table(&prefix, owning, table, PrettyPrintStyle::NoColor)" +--- +player (public) + Columns: + id U64 primary key, auto-increment + name String + rank U32 unique + nickname Option + joined_at Timestamp + avatar Thumbnail + color Color default: (red = ()) + level U32 default: 1 + Indexes: + player_id_idx_btree btree (id) + player_name_idx_hash hash (name) + player_rank_idx_direct direct (rank) diff --git a/crates/schema/src/styled_writer.rs b/crates/schema/src/styled_writer.rs new file mode 100644 index 00000000000..5e32fe7b386 --- /dev/null +++ b/crates/schema/src/styled_writer.rs @@ -0,0 +1,126 @@ +use std::io::{self, Write}; + +use termcolor::{Buffer, Color, ColorSpec, WriteColor}; + +use crate::auto_migrate::PrettyPrintStyle; + +/// Color scheme for consistent formatting +#[derive(Debug, Clone)] +pub(crate) struct ColorScheme { + pub created: Color, + pub removed: Color, + pub changed: Color, + pub header: Color, + pub table_name: Color, + pub column_type: Color, + pub section_header: Color, + pub access: Color, + pub warning: Color, +} + +impl Default for ColorScheme { + fn default() -> Self { + Self { + created: Color::Green, + removed: Color::Red, + changed: Color::Yellow, + header: Color::Blue, + table_name: Color::Cyan, + column_type: Color::Magenta, + section_header: Color::Blue, + access: Color::Green, + warning: Color::Red, + } + } +} + +/// An indent-aware, optionally-coloured text buffer. +/// +/// Shared by the migration-plan formatter and the describe output, so both render +/// with the same colour scheme and the same colour/no-colour handling. +#[derive(Debug)] +pub(crate) struct StyledWriter { + buffer: Buffer, + colors: ColorScheme, + indent_level: usize, + indent_width: usize, +} + +impl StyledWriter { + pub(crate) fn new(style: PrettyPrintStyle, indent_width: usize) -> Self { + Self { + buffer: match style { + PrettyPrintStyle::NoColor => Buffer::no_color(), + PrettyPrintStyle::AnsiColor => Buffer::ansi(), + }, + colors: ColorScheme::default(), + indent_level: 0, + indent_width, + } + } + + pub(crate) fn colors(&self) -> &ColorScheme { + &self.colors + } + + pub(crate) fn indent(&mut self) { + self.indent_level += 1; + } + + pub(crate) fn dedent(&mut self) { + if self.indent_level > 0 { + self.indent_level -= 1; + } + } + + pub(crate) fn write_indent(&mut self) -> io::Result<()> { + let indent = " ".repeat(self.indent_width * self.indent_level); + self.buffer.write_all(indent.as_bytes()) + } + + pub(crate) fn write_plain(&mut self, text: &str) -> io::Result<()> { + self.buffer.write_all(text.as_bytes()) + } + + pub(crate) fn write_line(&mut self, text: impl AsRef) -> io::Result<()> { + self.write_indent()?; + self.buffer.write_all(text.as_ref().as_bytes())?; + self.buffer.write_all(b"\n") + } + + pub(crate) fn write_colored(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { + let mut spec = ColorSpec::new(); + if let Some(c) = color { + spec.set_fg(Some(c)); + } + if bold { + spec.set_bold(true); + } + self.buffer.set_color(&spec)?; + self.buffer.write_all(text.as_bytes())?; + self.buffer.reset()?; + Ok(()) + } + + pub(crate) fn write_colored_line(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { + self.write_indent()?; + self.write_colored(text, color, bold)?; + self.buffer.write_all(b"\n") + } + + pub(crate) fn write_with_background(&mut self, text: &str, bg: Color, bold: bool) -> io::Result<()> { + let mut spec = ColorSpec::new(); + spec.set_bg(Some(bg)); + if bold { + spec.set_bold(true); + } + self.buffer.set_color(&spec)?; + self.buffer.write_all(text.as_bytes())?; + self.buffer.reset()?; + Ok(()) + } + + pub(crate) fn into_string(self) -> String { + String::from_utf8(self.buffer.into_inner()).expect("StyledWriter only writes &str, so output is UTF-8") + } +} diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 3ec8f5b7141..423d4524e35 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -1580,7 +1580,7 @@ log = "0.4" self.spacetime(&["describe", "--server", &self.server_url, identity.as_str()]) } - /// Describes the database schema anonymously (requires --json). + /// Describes the database schema anonymously, passing `--json` for JSON output. pub fn describe_anon(&self) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; diff --git a/crates/smoketests/tests/cluster/describe.rs b/crates/smoketests/tests/cluster/describe.rs index 72d66c57008..f4caedc93d9 100644 --- a/crates/smoketests/tests/cluster/describe.rs +++ b/crates/smoketests/tests/cluster/describe.rs @@ -1,37 +1,72 @@ use spacetimedb_smoketests::Smoketest; -/// Check describing a module +/// Check describing a module, as human-readable text and as JSON #[test] fn test_describe() { let test = Smoketest::builder().precompiled_module("describe").build(); - let identity = test.database_identity.as_ref().unwrap(); + let identity = test.database_identity.as_ref().unwrap().as_str(); + let describe = |args: &[&str]| -> String { + let full = [&["describe", "--server", test.server_url.as_str()][..], args].concat(); + test.spacetime(&full).unwrap() + }; - // Describe the whole module - test.spacetime(&["describe", "--server", &test.server_url, "--json", identity]) - .unwrap(); + // Describe the whole module: text by default, uncoloured because stdout is piped, and the + // unstable warning goes to stderr. + let text = describe(&[identity]); + for expected in [ + "Tables", + "person (private)", + "name", + "String", + "Reducers", + "add(name: String)", + "say_hello()", + ] { + assert!(text.contains(expected), "expected {expected:?} in:\n{text}"); + } + assert!( + !text.contains('\x1b'), + "piped describe output has ANSI escapes: {text:?}" + ); + assert!( + !text.contains("UNSTABLE"), + "unstable warning leaked into stdout:\n{text}" + ); - // Describe a specific reducer - test.spacetime(&[ - "describe", - "--server", - &test.server_url, - "--json", - identity, - "reducer", - "say_hello", - ]) - .unwrap(); + // Describing a single entity prints only that entity. + assert_eq!( + describe(&[identity, "tables", "person"]), + "person (private)\n Columns:\n name String\n" + ); + assert_eq!(describe(&[identity, "reducers", "say_hello"]), "say_hello()\n"); + + // `--format json` is the same as `--json`, for the whole module and for each entity. + let entities: [&[&str]; 3] = [&[], &["tables", "person"], &["reducers", "say_hello"]]; + for entity in entities { + let parse = |flag: &[&str]| -> serde_json::Value { + let args = [flag, &[identity], entity].concat(); + let out = describe(&args); + serde_json::from_str(&out).unwrap_or_else(|e| panic!("describe {args:?} is not JSON: {e}\n{out}")) + }; + assert_eq!(parse(&["--json"]), parse(&["--format", "json"]), "entity {entity:?}"); + } - // Describe a specific table - test.spacetime(&[ + // `--json` conflicts with an explicit `--format`. + let out = test.spacetime_cmd(&[ "describe", "--server", &test.server_url, "--json", + "--format", + "text", identity, - "table", - "person", - ]) - .unwrap(); + ]); + assert_eq!( + out.status.code(), + Some(2), + "`--json --format text` should be a usage error" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("cannot be used with"), "unexpected stderr: {stderr}"); } diff --git a/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md b/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md index 3a7d9661e39..0e3176957d7 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md +++ b/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md @@ -928,7 +928,8 @@ spacetime delete # Delete database spacetime logs # View logs spacetime logs --follow # Stream logs spacetime sql "SELECT * FROM t" # Run SQL query -spacetime describe --json # Show schema +spacetime describe # Show schema (human-readable) +spacetime describe --json # Show schema as JSON (machine-readable) spacetime call reducer arg1 arg2 # Call reducer # Code generation diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index b293fd95c20..d41acc59e7e 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -285,18 +285,24 @@ Run `spacetime help call` for more detailed information. Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime describe [OPTIONS] --json [describe_parts]...` +**Usage:** `spacetime describe [OPTIONS] [describe_parts]...` Run `spacetime help describe` for more detailed information. ###### **Arguments:** -* `` — Describe arguments: [DATABASE] [ENTITY_TYPE ENTITY_NAME] +* `` — Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]] ###### **Options:** -* `--json` — Output the schema in JSON format. Currently required; in the future, omitting this will give human-readable output. +* `--format ` — Output format for the schema + + Default value: `text` + + Possible values: `text`, `json` + +* `--json` — Output the schema in JSON format. Shorthand for `--format json`. * `--anonymous` — Perform this action with an anonymous identity * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index d66ee69e330..229e1028656 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -81,9 +81,11 @@ spacetime logs my-database -f # follow logs spacetime logs my-database -n 100 # up to 100 log lines # Describe schema +# (without --json, output is human-readable text) spacetime describe my-database --json -spacetime describe my-database table users --json -spacetime describe my-database reducer my_reducer --json +spacetime describe my-database tables --json # also reducers, procedures, types +spacetime describe my-database tables users --json +spacetime describe my-database reducers my_reducer --json ``` ### Database Management