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.