Skip to content

Iron Log

Iron Log is a weight-lifting tracker built as a Tauri desktop app with a Nuxt frontend. It’s the reference example for Ontogen — a small but complete application that exercises the full pipeline from schema definitions through generated TypeScript client code.

Four entity definitions produce 36 generated files. The entire backend — persistence, CRUD, API, HTTP handlers, and IPC commands — comes from those four annotated structs plus a build.rs.

The source lives at examples/iron-log/ in the Ontogen repository.

A weight-lifting tracker needs a few things:

  • Exercises: the movements you do (bench press, squat, deadlift). Each has a name, muscle group, and equipment type.
  • Workouts: a training session on a specific date. Can have a name, duration, and notes.
  • Workout Sets: individual sets within a workout — weight, reps, RPE. Each set belongs to a workout and an exercise.
  • Tags: labels you can attach to workouts (e.g., “push day”, “deload”, “PR”).

The relationships: workout sets belong to both a workout and an exercise (two belongs_to relations). Workouts have a many-to-many relationship with tags through a junction table.

The simplest entity. No relations, just data fields.

#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]
#[ontology(entity, table = "exercises")]
pub struct Exercise {
#[ontology(id)]
pub id: String,
pub name: String,
pub muscle_group: String,
pub equipment: String,
#[serde(default)]
pub notes: Option<String>,
}

Straightforward: a string ID, required fields for name/muscle group/equipment, and an optional notes field. No annotations beyond id needed — everything else is plain data.

Even simpler. Just a name.

#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]
#[ontology(entity, table = "tags")]
pub struct Tag {
#[ontology(id)]
pub id: String,
pub name: String,
}

This one has a relation — workouts can have tags.

#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]
#[ontology(entity, table = "workouts")]
pub struct Workout {
#[ontology(id)]
pub id: String,
#[serde(default)]
pub name: Option<String>,
pub date: String,
#[serde(default)]
pub duration_minutes: Option<i32>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
#[ontology(relation(many_to_many, target = "Tag"))]
pub tags: Vec<String>,
pub created_at: String,
}

The tags field is annotated with relation(many_to_many, target = "Tag"). This triggers:

  • A workout_tags junction table entity in SeaORM generation.
  • sync_junction() calls in the store’s create_workout and update_workout methods.
  • populate_workout_relations() that loads tag IDs from the junction table on reads.

Two belongs_to relations — every set references both a workout and an exercise.

#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]
#[ontology(entity, table = "workout_sets")]
pub struct WorkoutSet {
#[ontology(id)]
pub id: String,
#[ontology(relation(belongs_to, target = "Workout"))]
pub workout_id: String,
#[ontology(relation(belongs_to, target = "Exercise"))]
pub exercise_id: String,
pub set_number: i32,
pub weight_grams: i32,
pub reps: i32,
#[serde(default)]
pub rpe: Option<i32>,
#[serde(default)]
pub notes: Option<String>,
}

The workout_id and exercise_id fields are foreign keys. The belongs_to annotations tell Ontogen to generate SeaORM relation definitions pointing to the Workout and Exercise entities.

Notice weight_grams: i32 instead of a float. Storing weight as integer grams avoids floating-point precision issues — a deliberate domain modeling choice, not an Ontogen requirement.

From these four structs, cargo build produces 36 files across the project. Here’s the breakdown.

src/persistence/db/entities/generated/
mod.rs
exercise.rs
tag.rs
workout.rs
workout_set.rs
workout_tags.rs # junction table for Workout <-> Tag

Each entity file has a Model struct with SeaORM column annotations, a Relation enum, and ActiveModelBehavior. The workout.rs entity has a Related<tag::Entity> implementation that routes through the workout_tags junction table.

The junction table (workout_tags.rs) is a full SeaORM entity with BelongsTo relations to both workout and tag.

src/persistence/db/conversions/generated/
mod.rs
exercise.rs
tag.rs
workout.rs
workout_set.rs

Each contains from_model() (SeaORM model to schema type) and to_active_model() (schema type to SeaORM active model). For workout, from_model() initializes tags: Vec::new() because the junction data is loaded separately by populate_workout_relations().

src/schema/dto/
mod.rs
exercise.rs
tag.rs
workout.rs
workout_set.rs

Each contains CreateEntityInput and UpdateEntityInput. The create input has all fields. The update input wraps every non-ID field in Option for partial updates. Both have Deserialize, JsonSchema, and specta::Type derives.

src/store/generated/
mod.rs
exercise.rs
tag.rs
workout.rs
workout_set.rs

Each contains the five CRUD methods (list_*, get_*, create_*, update_*, delete_*) plus a EntityUpdate struct with an apply() method and From implementations for DTO-to-entity conversion.

The workout.rs store file is the most interesting because of the many-to-many relation. Its create_workout method:

  1. Calls hooks::before_create.
  2. Checks the id (iron-log’s id strategy is the default, IdStrategy::Provided, so a missing id is AppError::WorkoutIdRequired and a taken one AppError::WorkoutAlreadyExists) and inserts the workout row.
  3. Calls sync_junction("workout_tags", ...) to populate the junction table.
  4. Reloads the workout (to get populated relations).
  5. Emits a change event.
  6. Calls hooks::after_create.
src/store/hooks/
mod.rs
exercise.rs
tag.rs
workout.rs
workout_set.rs

Scaffolded once, never overwritten. Each has before_create, after_create, before_update, after_update, before_delete, after_delete — all with empty bodies that return Ok(()). In Iron Log, they’re left empty because the app doesn’t need pre/post-processing hooks.

src/api/v1/generated/
mod.rs
exercise.rs
tag.rs
workout.rs
workout_set.rs

Thin forwarding layer. Each function (e.g., list, get_by_id, create) takes a &Store reference and delegates to the corresponding store method.

src/api/transport/http/generated.rs
src/api/transport/ipc/generated.rs

The HTTP file generates Axum route handlers that speak JSON:API: resource routes for each entity, relationship and related-resource endpoints for workouts and workout sets, the /api/stats/workout custom op, and the /api/events/… streams. The IPC file generates Tauri command handlers. Both call through the same API layer. Iron Log: the wire shows the HTTP requests and responses, captured from a running server.

The IPC file contains 20 commands (5 per entity x 4 entities). Each command extracts State<'_, Arc<AppState>>, gets the store, calls the API function, and maps errors to strings.

../src-nuxt/app/generated/transport.ts
../src-nuxt/app/generated/types.ts
../src-nuxt/app/admin/generated/admin-registry.ts

A split transport layer that provides typed functions for every CRUD operation. Detects at runtime whether Tauri IPC is available and routes accordingly.

Iron Log’s build script uses the Pipeline builder. The full file is around 75 lines; here it is, verbatim from examples/iron-log/src-tauri/build.rs minus one long explanatory comment:

use std::path::PathBuf;
use ontogen::clients::ClientGenerator;
use ontogen::servers::{NamingConfig, ServerGenerator};
use ontogen::{ClientsConfig, IdStrategy, Pipeline, ServersConfig};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
ontogen::emit_rerun_directives_excluding(
&PathBuf::from("src/api/v1"),
&["generated"],
);
let servers_config = ServersConfig {
api_dir: "src/api/v1".into(),
state_type: "AppState".into(),
service_import_path: "crate::api::v1".into(),
types_import_path: "crate::schema".into(),
state_import: "crate::AppState".into(),
naming: NamingConfig::default(),
generators: vec![
ServerGenerator::HttpAxum {
output: "src/api/transport/http/generated.rs".into(),
},
ServerGenerator::TauriIpc {
output: "src/api/transport/ipc/generated.rs".into(),
},
],
sse_route_overrides: Default::default(),
route_prefix: None,
store_type: Some("Store".into()),
store_import: Some("crate::store::Store".into()),
pagination: None,
extra_surfaces: vec![],
// The pipeline scans its schema directory for `AppError`.
error_source_dir: None,
};
let clients_config = ClientsConfig {
generators: vec![
ClientGenerator::HttpTauriIpcSplit {
output: "../src-nuxt/app/generated/transport.ts".into(),
bindings_path: "../src-nuxt/app/generated/types.ts".into(),
},
ClientGenerator::AdminRegistry {
output: "../src-nuxt/app/admin/generated/admin-registry.ts".into(),
},
],
store_type: Some("Store".into()),
store_import: Some("crate::store::Store".into()),
..ClientsConfig::new("src/api/v1", "AppState", "crate::api::v1", "crate::schema", "crate::AppState")
};
let pipeline = Pipeline::new("src/schema")
.seaorm("src/persistence/db/entities/generated", "src/persistence/db/conversions/generated")
.dtos("src/schema/dto")
.store("src/store/generated", Some::<PathBuf>("src/store/hooks".into()))
// Workout.name is Option<String>, so SlugFromField is out; the
// caller supplies every id.
.store_id_strategy(IdStrategy::Provided)
.api("src/api/v1/generated", "AppState")
.servers(servers_config)
.clients(clients_config);
pipeline.build().unwrap_or_else(|e| {
e.emit_cargo_warning();
panic!("ontogen pipeline failed: {e}");
});
tauri_build::build();
}

Two things worth noticing:

  • ..ClientsConfig::new(...) — the constructor takes the five inputs client generation cannot infer and sets every other field to an inert default (no TypeScript formatter, no route prefix, no pagination, no label overrides). The struct literal names only what Iron Log changes, so a field added to ClientsConfig later does not break this build.rs.
  • error_source_dir — left None because Pipeline fills it with the schema directory. The generated HTTP server scans that directory for enum AppError and maps each variant to an HTTP status and code. A direct gen_servers caller sets it, and passes &schema as the first argument of gen_servers and gen_clients.

Pipeline runs every stage in dependency order regardless of method-call order. It also threads each stage’s typed output (SchemaOutput → SeaOrmOutput → ApiOutput → ServersOutput / ()) into the next, applies schema_module_path defaults to StoreConfig/ApiConfig, and passes schema.entities to the servers and clients stages, so the HTTP resources and the admin-registry field metadata come from the parsed schema.

The hand-written Store type provides the infrastructure that generated CRUD code relies on:

pub struct Store {
pub db: Arc<DatabaseConnection>,
change_tx: broadcast::Sender<EntityChange>,
}

It exposes:

  • db() — returns the database connection for SeaORM queries.
  • emit_change() — broadcasts change events (created/updated/deleted) that the UI can subscribe to.
  • sync_junction() — replaces junction table rows for many-to-many updates, inserting them in list order.
  • load_junction_ids() — loads related IDs from a junction table with ORDER BY rowid, so a list comes back in the order it was written.

The generated CRUD methods are implemented as impl Store blocks, so they have access to all of this.

The AppError in src/schema/mod.rs declares {Entity}NotFound, {Entity}IdRequired and {Entity}AlreadyExists for each of the four entities, plus DbError. It depends on ontogen-core (see Creating records).

iron-log/
src-tauri/
build.rs # Ontogen pipeline
Cargo.toml
src/
schema/
exercise.rs # 4 entity definitions
tag.rs # (you write these)
workout.rs
workout_set.rs
mod.rs
dto/ # generated input types
persistence/
db/
entities/generated/ # generated SeaORM entities
conversions/generated/ # generated model conversions
store/
generated/ # generated CRUD methods
hooks/ # scaffolded lifecycle hooks
mod.rs # Store struct (you write this)
api/
v1/
generated/ # generated API modules
mod.rs
transport/
http/generated.rs # generated Axum handlers
ipc/generated.rs # generated Tauri IPC commands
lib.rs # AppState definition
main.rs # Tauri app entry point
src-nuxt/
app/
generated/
transport.ts # generated TypeScript client

The pattern is consistent: you write schema files and infrastructure code (Store, AppState, mod.rs files). Ontogen fills in the generated/ directories and scaffolds hooks. Your code and generated code live side by side but never overlap.

From the examples/iron-log/ directory:

Terminal window
# Install frontend dependencies
cd src-nuxt && npm install && cd ..
# Build and run the Tauri app
cd src-tauri && cargo tauri dev

The Tauri build triggers build.rs, which runs the Ontogen pipeline and generates all 36 files before compilation. The Nuxt dev server picks up the generated TypeScript client.

To serve the HTTP API without Tauri, run the headless binary. It creates its SQLite tables from the generated SeaORM entities, in memory unless IRON_LOG_DB names a file:

Terminal window
cd src-tauri && PORT=39104 cargo run --bin iron-log-http # without PORT it serves on 3004

SQLite enforces the entities’ foreign keys, so deleting a row that another row still references (a tagged workout, a workout with sets, a tag in use) answers 500 db_error (backlog item B-DLFK tracks the fix).

If you’re exploring the example to understand Ontogen, start here:

  1. src/schema/workout.rs — the most interesting entity because of the many-to-many tag relation.
  2. src/store/generated/workout.rs — see how the relation drives junction table sync in create/update.
  3. src/persistence/db/entities/generated/workout_tags.rs — the generated junction table entity.
  4. src/api/transport/ipc/generated.rs — 20 Tauri IPC commands, all generated from 4 entities.
  5. build.rs — the full pipeline in one file, six stages, explicit data flow.

The progression from schema definition to transport handler is the point. One annotated struct becomes a SeaORM entity, a conversion layer, a DTO pair, five CRUD store methods, five API forwarding functions, five JSON:API handlers, five IPC commands, and five TypeScript client functions. Multiply by four entities, and you have a complete backend from 80 lines of schema code.