Console commands

The tables are ready, but they're empty. Promoters send their line-ups as JSON Lines: one concert per line. Let's give the staff a command that imports them:

{"slug":"paper-lanterns-hamburg","artist":"Paper Lanterns","support":["Quiet Harbor"],"starts_at":"2026-11-14T20:00:00Z","price":"32.50","capacity":400,"venue":"Riverside Hall","city":"Hamburg"}
{"slug":"midnight-owls-berlin","artist":"The Midnight Owls","support":[],"starts_at":"2026-11-21T19:30:00Z","price":"45.00","capacity":1200,"venue":"Glasshouse","city":"Berlin"}

Each line becomes a ConcertListing:

use chrono::DateTime;
use chrono::Utc;
use rust_decimal::Decimal;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct ConcertListing {
    pub slug: String,
    pub artist: String,
    pub support: Vec<String>,
    pub starts_at: DateTime<Utc>,
    pub price: Decimal,
    pub capacity: i32,
    pub venue: String,
    pub city: String,
}

Importing one concert

Promoters send the whole season every time, so importing must be safe to repeat: a concert that's already there gets updated, and a new one gets created. Venues work the same way. The ConcertImporter does it with the generated queries:

#[singleton]
pub struct ConcertImporter;

impl ConcertImporter {
    pub async fn import(
        &self,
        executor: &impl Executor,
        listing: &ConcertListing,
    ) -> anyhow::Result<Imported> {
        let venue = match Venue::query()
            .name
            .eq(listing.venue.clone())
            .find(executor)
            .await?
        {
            Lookup::Found(venue) => venue,
            Lookup::Missing => {
                Venue::create(VenueDraft {
                    name: listing.venue.clone(),
                    city: listing.city.clone(),
                })
                .run(executor)
                .await?
            }
        };
        let support = Json::new(listing.support.clone());

        match Concert::query()
            .slug
            .eq(listing.slug.clone())
            .find(executor)
            .await?
        {
            Lookup::Found(existing) => {
                let updated = Concert {
                    artist: listing.artist.clone(),
                    support,
                    starts_at: listing.starts_at,
                    price: listing.price,
                    capacity: listing.capacity,
                    venue: Key::of(&venue),
                    ..existing
                };

                match updated.save(executor).await? {
                    Saving::Saved => Ok(Imported::Updated),
                    Saving::Missing => {
                        anyhow::bail!("the concert '{}' vanished while importing", listing.slug)
                    }
                }
            }
            Lookup::Missing => {
                Concert::create(ConcertDraft {
                    slug: listing.slug.clone(),
                    artist: listing.artist.clone(),
                    support,
                    starts_at: listing.starts_at,
                    price: listing.price,
                    capacity: listing.capacity,
                    venue: Key::of(&venue),
                })
                .run(executor)
                .await?;

                Ok(Imported::Added)
            }
        }
    }
}

Here's what the generated API gives you:

  • name and slug are unique, so .eq(...) leads to exactly one row, and find answers with Lookup::Found or Lookup::Missing.

  • create takes a Draft: the model without the fields the database fills in, like the primary key. Margaret generates a Draft for every model with a database default, in crate::margaret::models. It returns the created model.

  • save writes every column of a model you already have, and tells you with Saving whether the row was still there.

The importer takes any Executor, so it works with the database itself or inside a transaction.

Writing commands

A command is a singleton with #[console_command] and a #[process] method that does the work. This one imports the whole file in one transaction, so the staff never end up with half a season:

#[singleton]
#[console_command(
    name = "import-concerts",
    description = "Imports concerts from a promoter's JSON Lines file"
)]
pub struct ImportConcerts {
    database: Arc<Database>,
    dry_run: bool,
    file: PathBuf,
    importer: Arc<ConcertImporter>,
}

impl ImportConcerts {
    #[constructor]
    pub fn create(
        database: Arc<Database>,
        importer: Arc<ConcertImporter>,
        #[console_argument(positional)] file: PathBuf,
        #[console_argument(from = "dry-run")] dry_run: bool,
    ) -> anyhow::Result<Self> {
        Ok(Self {
            database,
            dry_run,
            file,
            importer,
        })
    }

    #[process]
    pub async fn run(
        &self,
        cancellation_token: CancellationToken,
    ) -> anyhow::Result<CommandOutcome> {
        let listings =
            serde_json::Deserializer::from_str(&tokio::fs::read_to_string(&self.file).await?)
                .into_iter::<ConcertListing>()
                .collect::<Result<Vec<_>, _>>()?;

        if listings.is_empty() {
            eprintln!("There are no concerts to import.");

            return Ok(CommandOutcome::Failed);
        }

        let mut connection = self.database.connection().await?;
        let transaction = connection.transaction(Isolation::ReadCommitted).await?;

        for listing in &listings {
            if cancellation_token.is_cancelled() {
                transaction.rollback().await?;
                eprintln!("Stopped. Nothing was imported.");

                return Ok(CommandOutcome::Failed);
            }

            match self.importer.import(&transaction, listing).await? {
                Imported::Added => println!("added   {}", listing.slug),
                Imported::Updated => println!("updated {}", listing.slug),
            }
        }

        if self.dry_run {
            transaction.rollback().await?;
        } else {
            transaction.commit().await?;
        }

        Ok(CommandOutcome::Succeeded)
    }
}

A dry run does all the work, then rolls the transaction back:

$ box_office import-concerts concerts.jsonl --dry-run
added   paper-lanterns-hamburg
added   midnight-owls-berlin

$ box_office import-concerts concerts.jsonl
added   paper-lanterns-hamburg
added   midnight-owls-berlin

$ box_office import-concerts concerts.jsonl
updated paper-lanterns-hamburg
updated midnight-owls-berlin

Flags and arguments

Flags come from #[console_argument] constructor parameters. A bool becomes a flag like --dry-run, an Option makes a value optional, and #[console_argument(positional)] takes an argument without a name. The help text is generated for you:

$ box_office import-concerts --help
Imports concerts from a promoter's JSON Lines file

Usage: box_office import-concerts [OPTIONS] <file>

Arguments:
  <file>

Options:
      --dry-run
  -h, --help     Print help

Exit codes

Return CommandOutcome::Succeeded to exit with 0, and CommandOutcome::Failed to exit with 1. An error also exits with 1, after printing it. When a promoter sends a concert with a capacity of 0, the minimum = 1 check of the capacity column refuses it, the import fails with ActiveRecordError::CheckViolation, and nothing from that file is kept. Scripts and cron jobs can rely on that.

Long-running commands

A command's #[process] method may take a CancellationToken. It's cancelled when someone presses Ctrl+C, so the import above can roll back and stop cleanly instead of being cut off halfway.