From ba0cc04e4c2d7bee8c413a0d42fe8db3d68fa502 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 14:29:46 +0000 Subject: [PATCH 01/11] Add docs for migration --- cot-test/tests/doc_code_blocks.rs | 14 +- docs/databases/migrations.md | 222 ++++++++++++++++++++++++++++++ docs/site/src/main.rs | 1 + 3 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 docs/databases/migrations.md diff --git a/cot-test/tests/doc_code_blocks.rs b/cot-test/tests/doc_code_blocks.rs index bef843c68..dcf06042d 100644 --- a/cot-test/tests/doc_code_blocks.rs +++ b/cot-test/tests/doc_code_blocks.rs @@ -75,7 +75,19 @@ fn test_md(trials: &mut Vec, file_name: &str, file_contents: &str) { TestConfig::Default, ) }; - let lang = lang.expect("unknown language"); + + let lang = lang.unwrap_or_else(|err| { + let file_info = format!( + "{}:{}:{}", + file_name, node_data.sourcepos.start.line, node_data.sourcepos.start.column + ); + panic!( + "{} in {}\ncontent: {}", + err.to_string(), + file_info, + code_block.literal.replace("\n", "\n ") + ) + }); if let Some(runner) = TEST_RUNNERS.get().unwrap().get(&(lang, test_config)) { let literal = if lang == TestLanguage::Rust { diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md new file mode 100644 index 000000000..ca929828d --- /dev/null +++ b/docs/databases/migrations.md @@ -0,0 +1,222 @@ +--- +title: Migrations +--- + +Migrations are how Cot's ORM keeps your database schema in sync with your Model structs. Think of them as version control for your schema: every time you add a model, add a field, or remove something, you generate a migration file that records exactly what changed. Cot then applies those files, in order, to bring any database (a fresh one, or the one in production) up to date. + +Migration files are plain Rust files. There's no separate DSL to learn and nothing gets serialized to JSON or YAML behind your back. If you can read Rust, you can read a migration. + +## Creating migrations + +Once you've added or changed a [`#[model]`](attr@cot::db::model) struct, run: + +```bash +cot migration make +``` + +Cot compares your current models against the models recorded in the most recent migrations, works out what changed, and writes a new migration file for you. If nothing changed, it will just tell you so and exit without creating anything. + +A few things worth knowing about how this works: + +* The **app name** for a migration is your crate name by default. You can override it with `--app-name` if you'd like, though in most single-crate projects you'll never need to. +* Migrations are generated per crate. If you have a Cargo workspace with multiple crates, each one gets its own `migrations` directory and its own independent history. There's currently no way for a migration in one crate to be generated against models living in another crate in the same workspace, that's on the roadmap but not there yet. +* By default, migrations are written to `src/migrations/` inside your crate. You can point this elsewhere with `--output-dir` if needed. + +If you want an empty migration to hand-write yourself (useful for data migrations, or anything that isn't a simple schema change), use: + +```bash +cot migration new +``` + +This scaffolds a migration file with [`forwards`](struct@cot::db::migrations::Operation#method.forwards) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) stubs ready to fill in, rather than trying to auto-detect any model changes. + +## Running migrations + +Migrations are applied automatically whenever your project boots up, so a normal `cargo run` is all it takes. On startup, Cot creates a small internal table called `cot__migrations` (if it doesn't already exist) to keep track of which migrations have already been applied, then walks through your migrations in dependency order and applies anything new. Migrations that are already recorded as applied are skipped, so it's always safe to boot the project again after pulling in new migrations. + +## Listing migrations + +To see what migrations exist across your project, run: + +```bash +cot migration list +``` + +This prints every migration Cot can find, one per line, alongside the app (crate) it belongs to. In a workspace, this walks all member crates. + +## Anatomy of a migration file + +Here's a trimmed down version of what `cot migration make` generates for a simple `Product` model: + +```rust,ignore +//! Create a new table for the Product model. +//! +//! Generated by cot CLI 0.6.0 on 2026-05-26 16:15:49+00:00 + +#[derive(Debug, Copy, Clone)] +pub(super) struct Migration; + +impl ::cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = "customers"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ + ::cot::db::migrations::Operation::create_model() + .table_name(::cot::db::Identifier::new("customers__product")) + .fields(&[ + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("id"), + as ::cot::db::DatabaseField>::TYPE, + ) + .auto() + .primary_key(), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("name"), + as ::cot::db::DatabaseField>::TYPE, + ), + ]) + .build(), + ]; +} + +#[derive(::core::fmt::Debug)] +#[::cot::db::model(model_type = "migration")] +struct _Product { + #[model(primary_key)] + id: cot::db::Auto, + name: cot::db::LimitedString<255>, +} +``` + +Every migration is just a unit struct that implements the [`Migration`](trait@cot::db::migrations::Migration) trait, made up of four consts: + +* **`APP_NAME`**: the app (crate) this migration belongs to. This, together with `MIGRATION_NAME`, is what Cot uses to identify a migration uniquely. +* **`MIGRATION_NAME`**: the migration's own name, matching the file name minus the extension (for example `m_0001_initial`). By convention, auto-generated names after the first one look like `m_0002_auto_20260527_004236`, a sequence number followed by a timestamp, since there's no more descriptive name to give them automatically. Migrations created with `cot migration new my_name` instead get `m_0002_my_name`. +* **`DEPENDENCIES`**: a list of other migrations (possibly in other apps) that must run before this one. See [Dependencies](#dependencies) below. +* **`OPERATIONS`**: the actual list of schema changes this migration performs, applied in order. See [Operations](#operations) below. + +You'll also notice a second struct below the `Migration` impl, prefixed with an underscore (`_Product` above). This is a snapshot of the model as it looked at the time this migration was generated, marked with `model_type = "migration"`. Cot uses these snapshots to work out what changed the next time you run `cot migration make`, by diffing your current models against the latest snapshot on record. You shouldn't need to touch these by hand, they exist purely so future migrations can be generated correctly. + +### The `migrations.rs` module + +Alongside your migration files, Cot maintains a `migrations.rs` module that lists all of them: + +```rust,ignore +//! +//! List of migrations for the current app. +//! +//! Generated by cot CLI 0.6.0 on 2026-05-27 00:42:36+00:00 + +pub mod m_0001_initial; +pub mod m_0002_auto_20260527_004236; + +/// The list of migrations for current app. +pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ + &m_0001_initial::Migration, + &m_0002_auto_20260527_004236::Migration, +]; +``` + +This file is regenerated in full every time you run `cot migration make` or `cot migration new`, so treat it as generated code. It's what your `App::migrations()` implementation typically returns, usually with the help of the [`wrap_migrations`](fn@cot::db::migrations::wrap_migrations) helper. + +## Operations + +An [`Operation`](struct@cot::db::migrations::Operation) describes one schema change, and knows how to apply itself both [`forwards`](struct@cot::db::migrations::Operation#method.forwards) (when migrating) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) (when rolling back). You'll rarely construct these by hand since `cot migration make` does it for you, but it helps to know what's available, especially if you're writing a custom migration. + +| Operation | What it does | +|---|---| +| `Operation::create_model()` | Creates a new table from a table name and a list of fields. Add `.if_not_exists()` if you want it to be a no-op when the table's already there. | +| `Operation::add_field()` | Adds a single field to an existing table. | +| `Operation::remove_field()` | Drops a single field from a table. | +| `Operation::remove_model()` | Drops a table entirely. | + +Each of these is built with a small builder, for example: + +```rust +# use cot::db::migrations::{Operation, Field}; +# use cot::db::{DatabaseField, Identifier}; +const OPERATION: Operation = Operation::add_field() + .table_name(Identifier::new("customers__product")) + .field(Field::new(Identifier::new("sku"), ::TYPE).unique()) + .build(); +``` + +### Custom operations + +Sometimes a schema change isn't expressible with the four operations above, or you need to run a data migration alongside a schema one. For that, there's [`Operation::custom()`](struct@cot::db::migrations::Operation#custom): + +```rust +use cot::db::Result; +use cot::db::migrations::{MigrationContext, Operation, migration_op}; + +#[migration_op] +async fn forwards(ctx: MigrationContext<'_>) -> Result<()> { + // run whatever queries you need against ctx.db + Ok(()) +} + +#[migration_op] +async fn backwards(ctx: MigrationContext<'_>) -> Result<()> { + // undo it + Ok(()) +} + +const OPERATION: Operation = Operation::custom(forwards).backwards(backwards).build(); +``` + +The `#[migration_op]` macro just takes care of the boilerplate needed to match the function signature Cot expects. `backwards` is optional. If you skip it and someone later tries to roll this migration back, Cot will simply return an error saying the backwards migration isn't implemented, rather than silently doing nothing. + +## Dependencies + +Migrations within the same app are always applied in the order their names sort in (which is why the auto-generated names are zero-padded and sequential). Across apps, or whenever ordering needs to be more explicit than that, you can add entries to `DEPENDENCIES`: + +* **`MigrationDependency::migration(app, migration_name)`**, for depending on a specific migration in a specific app being applied first. +* **`MigrationDependency::model(app, table_name)`**, for depending on whichever migration created a given model, without needing to know or hardcode its exact name. + +You'll see `cot migration make` add these automatically whenever a new foreign key points at a model that isn't being created in the very same migration. That's the most common reason you'd end up depending on another app: a `ForeignKey` field pointing at a model whose migration lives elsewhere. + +If Cot ever detects a cycle in your migration dependencies, it will refuse to run and tell you so, both when generating new migrations and when booting the project. + +## Rolling back migrations + +To undo migrations, use: + +```bash +cot migration rollback [--app ] [--dry-run] +``` + +`` can be given a few different ways: + +* The full migration name, e.g. `m_0002_add_sku` +* Just the number, e.g. `0002` +* The special value `zero`, meaning "roll back everything for this app" + +`--app` defaults to your crate name, the same as `APP_NAME` in your migrations. Rolling back to a specific migration is **exclusive**: that migration itself stays applied, and everything after it gets undone. + +Rollbacks aren't limited to a single app either. If another app has a migration that depends on one of the migrations you're rolling back (say, through a foreign key), that dependent migration gets rolled back too, so you're never left with a dangling reference. Migrations that were never applied in the first place are simply skipped. + +Because rolling back can have a wider blast radius than expected once cross-app dependencies are involved, it's worth checking first with: + +```bash +cot migration rollback --dry-run +``` + +This prints the exact rollback plan without touching your database at all, something like: + +```bash +Rollback dry run + +Target: + app: customers + migration: m_0001_initial + mode: zero (all migrations in app rolled back) + +Rollback plan: + 1. customers::m_0002_auto_20260527_004236 + 2. customers::m_0001_initial + +Summary: + to roll back: 2 + skipped: 0 + database changes: none +``` \ No newline at end of file diff --git a/docs/site/src/main.rs b/docs/site/src/main.rs index 3a93106a0..d59f2c3ac 100644 --- a/docs/site/src/main.rs +++ b/docs/site/src/main.rs @@ -43,6 +43,7 @@ impl Project for CotSiteProject { pages: vec![ md_page!("databases/overview"), md_page!("databases/queries"), + md_page!("databases/migrations"), ], }, GuideItem::Page(md_page!("admin-panel")), From 72a43cfa5467a7cac1a4c9a2c554f5b733e043d1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:36:26 +0000 Subject: [PATCH 02/11] chore(pre-commit.ci): auto fixes from pre-commit hooks --- docs/databases/migrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index ca929828d..1430a2137 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -219,4 +219,4 @@ Summary: to roll back: 2 skipped: 0 database changes: none -``` \ No newline at end of file +``` From 91d12256c2173636cccc5687c2f2b9bfe2179608 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 14:46:40 +0000 Subject: [PATCH 03/11] fix clippy --- cot-test/tests/doc_code_blocks.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cot-test/tests/doc_code_blocks.rs b/cot-test/tests/doc_code_blocks.rs index dcf06042d..6a02a9b31 100644 --- a/cot-test/tests/doc_code_blocks.rs +++ b/cot-test/tests/doc_code_blocks.rs @@ -82,10 +82,10 @@ fn test_md(trials: &mut Vec, file_name: &str, file_contents: &str) { file_name, node_data.sourcepos.start.line, node_data.sourcepos.start.column ); panic!( - "{} in {}\ncontent: {}", - err.to_string(), + "{} in {}\n{}", + err, file_info, - code_block.literal.replace("\n", "\n ") + code_block.literal.replace('\n', "\n ") ) }); From f8827a3a4acb4f60c399d7a71a7103d6a6409361 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 15:49:15 +0000 Subject: [PATCH 04/11] improve docs, add links --- cot-macros/src/lib.rs | 19 ------------------- cot/src/db/migrations.rs | 19 +++++++++++++++++++ docs/databases/migrations.md | 28 ++++++++++++++-------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/cot-macros/src/lib.rs b/cot-macros/src/lib.rs index 085d4b7cb..2ed45ab69 100644 --- a/cot-macros/src/lib.rs +++ b/cot-macros/src/lib.rs @@ -85,25 +85,6 @@ pub fn dbtest(_args: TokenStream, input: TokenStream) -> TokenStream { .into() } -/// An attribute macro that defines a custom migration operation. -/// -/// This macro simplifies writing custom migration operations by allowing you to -/// write them as regular `async` functions. It handles the necessary pinning -/// and boxing of the return type to make it compatible with the migration -/// engine. -/// -/// # Examples -/// -/// ``` -/// use cot::db::Result; -/// use cot::db::migrations::{MigrationContext, migration_op}; -/// -/// #[migration_op] -/// async fn my_migration(ctx: MigrationContext<'_>) -> Result<()> { -/// // Your migration logic here -/// Ok(()) -/// } -/// ``` #[proc_macro_attribute] pub fn migration_op(_args: TokenStream, input: TokenStream) -> TokenStream { let fn_input = parse_macro_input!(input as ItemFn); diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 1ac4590d1..c9a77a2c5 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -6,6 +6,25 @@ use std::fmt; use std::fmt::{Debug, Formatter}; use std::future::Future; +/// An attribute macro that defines a custom migration operation. +/// +/// This macro simplifies writing custom migration operations by allowing you to +/// write them as regular `async` functions. It handles the necessary pinning +/// and boxing of the return type to make it compatible with the migration +/// engine. +/// +/// # Examples +/// +/// ``` +/// use cot::db::Result; +/// use cot::db::migrations::{MigrationContext, migration_op}; +/// +/// #[migration_op] +/// async fn my_migration(ctx: MigrationContext<'_>) -> Result<()> { +/// // Your migration logic here +/// Ok(()) +/// } +/// ``` pub use cot_macros::migration_op; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index 1430a2137..0002ca38d 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -20,7 +20,7 @@ A few things worth knowing about how this works: * The **app name** for a migration is your crate name by default. You can override it with `--app-name` if you'd like, though in most single-crate projects you'll never need to. * Migrations are generated per crate. If you have a Cargo workspace with multiple crates, each one gets its own `migrations` directory and its own independent history. There's currently no way for a migration in one crate to be generated against models living in another crate in the same workspace, that's on the roadmap but not there yet. -* By default, migrations are written to `src/migrations/` inside your crate. You can point this elsewhere with `--output-dir` if needed. +* By default, migrations are written to the `src/migrations/` directory inside your crate. You can point this elsewhere with `--output-dir` if needed. If you want an empty migration to hand-write yourself (useful for data migrations, or anything that isn't a simple schema change), use: @@ -28,7 +28,7 @@ If you want an empty migration to hand-write yourself (useful for data migration cot migration new ``` -This scaffolds a migration file with [`forwards`](struct@cot::db::migrations::Operation#method.forwards) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) stubs ready to fill in, rather than trying to auto-detect any model changes. +This scaffolds a migration file with `forwards` and `backwards` stubs ready to fill in, rather than trying to auto-detect any model changes. ## Running migrations @@ -91,8 +91,8 @@ struct _Product { Every migration is just a unit struct that implements the [`Migration`](trait@cot::db::migrations::Migration) trait, made up of four consts: * **`APP_NAME`**: the app (crate) this migration belongs to. This, together with `MIGRATION_NAME`, is what Cot uses to identify a migration uniquely. -* **`MIGRATION_NAME`**: the migration's own name, matching the file name minus the extension (for example `m_0001_initial`). By convention, auto-generated names after the first one look like `m_0002_auto_20260527_004236`, a sequence number followed by a timestamp, since there's no more descriptive name to give them automatically. Migrations created with `cot migration new my_name` instead get `m_0002_my_name`. -* **`DEPENDENCIES`**: a list of other migrations (possibly in other apps) that must run before this one. See [Dependencies](#dependencies) below. +* **`MIGRATION_NAME`**: the migration's own name, matching the file name minus the extension (for example `m_0001_initial`). By convention, auto-generated names after the first one look like `m_0002_auto_20260527_004236`, a sequence number followed by a timestamp. Migrations created with `cot migration new my_name` instead get `m_0002_my_name`. +* **`DEPENDENCIES`**: a list of other migrations (including those in other apps) that must run before this one. See [Dependencies](#dependencies) below. * **`OPERATIONS`**: the actual list of schema changes this migration performs, applied in order. See [Operations](#operations) below. You'll also notice a second struct below the `Migration` impl, prefixed with an underscore (`_Product` above). This is a snapshot of the model as it looked at the time this migration was generated, marked with `model_type = "migration"`. Cot uses these snapshots to work out what changed the next time you run `cot migration make`, by diffing your current models against the latest snapshot on record. You shouldn't need to touch these by hand, they exist purely so future migrations can be generated correctly. @@ -117,7 +117,7 @@ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ ]; ``` -This file is regenerated in full every time you run `cot migration make` or `cot migration new`, so treat it as generated code. It's what your `App::migrations()` implementation typically returns, usually with the help of the [`wrap_migrations`](fn@cot::db::migrations::wrap_migrations) helper. +This file is regenerated in full every time you run `cot migration make` or `cot migration new`, so treat it as generated code. It's what your [`App::migrations`](trait@cot::project::App#method.migrations) implementation typically returns, usually with the help of the [`wrap_migrations`](fn@cot::db::migrations::wrap_migrations) helper. ## Operations @@ -125,10 +125,10 @@ An [`Operation`](struct@cot::db::migrations::Operation) describes one schema cha | Operation | What it does | |---|---| -| `Operation::create_model()` | Creates a new table from a table name and a list of fields. Add `.if_not_exists()` if you want it to be a no-op when the table's already there. | -| `Operation::add_field()` | Adds a single field to an existing table. | -| `Operation::remove_field()` | Drops a single field from a table. | -| `Operation::remove_model()` | Drops a table entirely. | +| `Operation::create_model` | Creates a new table from a table name and a list of fields. Add `.if_not_exists()` if you want it to be a no-op when the table's already there. | +| `Operation::add_field` | Adds a single field to an existing table. | +| `Operation::remove_field` | Drops a single field from a table. | +| `Operation::remove_model` | Drops a table entirely. | Each of these is built with a small builder, for example: @@ -143,7 +143,7 @@ const OPERATION: Operation = Operation::add_field() ### Custom operations -Sometimes a schema change isn't expressible with the four operations above, or you need to run a data migration alongside a schema one. For that, there's [`Operation::custom()`](struct@cot::db::migrations::Operation#custom): +Sometimes a schema change isn't expressible with the four operations above, or you need to run a data migration alongside a schema one. For that, there's [`Operation::custom`](struct@cot::db::migrations::Operation#custom): ```rust use cot::db::Result; @@ -164,14 +164,14 @@ async fn backwards(ctx: MigrationContext<'_>) -> Result<()> { const OPERATION: Operation = Operation::custom(forwards).backwards(backwards).build(); ``` -The `#[migration_op]` macro just takes care of the boilerplate needed to match the function signature Cot expects. `backwards` is optional. If you skip it and someone later tries to roll this migration back, Cot will simply return an error saying the backwards migration isn't implemented, rather than silently doing nothing. +The `#[migration_op](macro@cot::db::migrations::migration_op)` macro just takes care of the boilerplate needed to match the function signature Cot expects. `backwards` is optional. If you skip it and someone later tries to roll this migration back, Cot will simply return an error saying the backwards migration isn't implemented, rather than silently doing nothing. ## Dependencies Migrations within the same app are always applied in the order their names sort in (which is why the auto-generated names are zero-padded and sequential). Across apps, or whenever ordering needs to be more explicit than that, you can add entries to `DEPENDENCIES`: -* **`MigrationDependency::migration(app, migration_name)`**, for depending on a specific migration in a specific app being applied first. -* **`MigrationDependency::model(app, table_name)`**, for depending on whichever migration created a given model, without needing to know or hardcode its exact name. +* **[`MigrationDependency::migration`](struct@cot::db::migrations::MigrationDependency#method.migration)**, for depending on a specific migration in a specific app being applied first. +* **[`MigrationDependency::model`](struct@cot::db::migrations::MigrationDependency#method.model)**, for depending on whichever migration created a given model, without needing to know or hardcode its exact name. You'll see `cot migration make` add these automatically whenever a new foreign key points at a model that isn't being created in the very same migration. That's the most common reason you'd end up depending on another app: a `ForeignKey` field pointing at a model whose migration lives elsewhere. @@ -195,7 +195,7 @@ cot migration rollback [--app ] [--dry-run] Rollbacks aren't limited to a single app either. If another app has a migration that depends on one of the migrations you're rolling back (say, through a foreign key), that dependent migration gets rolled back too, so you're never left with a dangling reference. Migrations that were never applied in the first place are simply skipped. -Because rolling back can have a wider blast radius than expected once cross-app dependencies are involved, it's worth checking first with: +Because rolling back can have a wider blast radius than expected once cross-app dependencies are involved, it's worth checking first with the `--dry-run` flag which shows a preview of what migrations will be undone: ```bash cot migration rollback --dry-run From 0f1ed4cf0472122434cd1688567fa7c490b70d9c Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 16:41:19 +0000 Subject: [PATCH 05/11] fix links --- docs/databases/migrations.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index 0002ca38d..857322546 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -121,16 +121,9 @@ This file is regenerated in full every time you run `cot migration make` or `cot ## Operations -An [`Operation`](struct@cot::db::migrations::Operation) describes one schema change, and knows how to apply itself both [`forwards`](struct@cot::db::migrations::Operation#method.forwards) (when migrating) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) (when rolling back). You'll rarely construct these by hand since `cot migration make` does it for you, but it helps to know what's available, especially if you're writing a custom migration. +An [`Operation`](struct@cot::db::migrations::Operation) describes one schema change, and knows how to apply itself both [`forwards`](struct@cot::db::migrations::Operation#method.forwards) (when migrating) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) (when rolling back). You'll rarely construct these by hand since `cot migration make` does it for you, but it's useful to know the shape, especially if you're writing a custom migration. -| Operation | What it does | -|---|---| -| `Operation::create_model` | Creates a new table from a table name and a list of fields. Add `.if_not_exists()` if you want it to be a no-op when the table's already there. | -| `Operation::add_field` | Adds a single field to an existing table. | -| `Operation::remove_field` | Drops a single field from a table. | -| `Operation::remove_model` | Drops a table entirely. | - -Each of these is built with a small builder, for example: +For example, adding a field to an existing table: ```rust # use cot::db::migrations::{Operation, Field}; @@ -141,9 +134,11 @@ const OPERATION: Operation = Operation::add_field() .build(); ``` +Besides [`add_field`](struct@cot::db::migrations::Operation#method.add_field), Cot ships builders for creating a model, removing a field, and removing a model. See the [`Operation`](struct@cot::db::migrations::Operation) for the full list of supported operations. + ### Custom operations -Sometimes a schema change isn't expressible with the four operations above, or you need to run a data migration alongside a schema one. For that, there's [`Operation::custom`](struct@cot::db::migrations::Operation#custom): +Sometimes a schema change isn't expressible with the built-in operations above, or you need to run a data migration alongside a schema one. For that, there's [`Operation::custom`](struct@cot::db::migrations::Operation#method.custom): ```rust use cot::db::Result; @@ -164,7 +159,7 @@ async fn backwards(ctx: MigrationContext<'_>) -> Result<()> { const OPERATION: Operation = Operation::custom(forwards).backwards(backwards).build(); ``` -The `#[migration_op](macro@cot::db::migrations::migration_op)` macro just takes care of the boilerplate needed to match the function signature Cot expects. `backwards` is optional. If you skip it and someone later tries to roll this migration back, Cot will simply return an error saying the backwards migration isn't implemented, rather than silently doing nothing. +The [`#[migration_op]`](attr@cot::db::migrations::migration_op) macro just takes care of the boilerplate needed to match the function signature Cot expects. `backwards` is optional. If you skip it and someone later tries to roll this migration back, Cot will simply return an error saying the backwards migration isn't implemented, rather than silently doing nothing. ## Dependencies From 1cbd90082e8c13ae5abe9f093bfdb86e26d7f91c Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 30 Jul 2026 20:22:53 +0000 Subject: [PATCH 06/11] address low hanging fruits --- docs/databases/migrations.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index 857322546..de5901a01 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -8,7 +8,7 @@ Migration files are plain Rust files. There's no separate DSL to learn and nothi ## Creating migrations -Once you've added or changed a [`#[model]`](attr@cot::db::model) struct, run: +Once you've added or changed a struct annotated with [`#[model]`](attr@cot::db::model), run: ```bash cot migration make @@ -57,12 +57,12 @@ Here's a trimmed down version of what `cot migration make` generates for a simpl pub(super) struct Migration; impl ::cot::db::migrations::Migration for Migration { - const APP_NAME: &'static str = "customers"; + const APP_NAME: &'static str = "ecommerce"; const MIGRATION_NAME: &'static str = "m_0001_initial"; const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[]; const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ ::cot::db::migrations::Operation::create_model() - .table_name(::cot::db::Identifier::new("customers__product")) + .table_name(::cot::db::Identifier::new("ecommerce__product")) .fields(&[ ::cot::db::migrations::Field::new( ::cot::db::Identifier::new("id"), @@ -117,7 +117,7 @@ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ ]; ``` -This file is regenerated in full every time you run `cot migration make` or `cot migration new`, so treat it as generated code. It's what your [`App::migrations`](trait@cot::project::App#method.migrations) implementation typically returns, usually with the help of the [`wrap_migrations`](fn@cot::db::migrations::wrap_migrations) helper. +This file is regenerated in full every time you run `cot migration make` or `cot migration new`, so treat it as generated code. It's what your [`App::migrations`](trait@cot::project::App#method.migrations) implementation typically returns. ## Operations From 8694a5c02fbca4e27909f7dc2a0e539f1e750ba1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 30 Jul 2026 21:08:16 +0000 Subject: [PATCH 07/11] format code snippets in error messages for doc tests to look rusty --- cot-test/src/lib.rs | 2 ++ cot-test/src/utils.rs | 53 +++++++++++++++++++++++++++++++ cot-test/tests/doc_code_blocks.rs | 21 ++++++------ 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 cot-test/src/utils.rs diff --git a/cot-test/src/lib.rs b/cot-test/src/lib.rs index 7224b2405..6c5d09240 100644 --- a/cot-test/src/lib.rs +++ b/cot-test/src/lib.rs @@ -1,3 +1,5 @@ +pub mod utils; + use std::fs; use std::path::PathBuf; use std::sync::{Mutex, MutexGuard, OnceLock}; diff --git a/cot-test/src/utils.rs b/cot-test/src/utils.rs new file mode 100644 index 000000000..dd23e78b5 --- /dev/null +++ b/cot-test/src/utils.rs @@ -0,0 +1,53 @@ +//! Test utilities + +/// Formats a code snippet for an error message using the style of rustc +/// diagnostics +/// +/// # Example +/// +/// ```text +/// --> migrations.md:201:1 +/// | +/// 201 | Rollback dry run +/// 202 | +/// 203 | Target: +/// 204 | app: customers +/// 205 | migration: m_0001_initial +/// | +/// ``` +pub fn format_code_snippet( + literal: &str, + file_name: &str, + start_line: usize, + start_col: usize, + max_lines: usize, +) -> String { + let lines: Vec<&str> = literal.lines().take(max_lines).collect(); + + // Width of the largest line number, for gutter alignment. + let last_line_num = start_line + lines.len().saturating_sub(1); + let gutter_width = last_line_num.to_string().len(); + + let mut out = String::new(); + out.push_str(&format!( + "{:width$}--> {}:{}:{}\n", + "", + file_name, + start_line, + start_col, + width = gutter_width + 1 + )); + out.push_str(&format!("{:width$} |\n", "", width = gutter_width)); + + for (i, line) in lines.iter().enumerate() { + let line_num = start_line + i; + out.push_str(&format!( + "{line_num:width$} | {line}\n", + width = gutter_width + )); + } + + out.push_str(&format!("{:width$} |", "", width = gutter_width)); + + out +} diff --git a/cot-test/tests/doc_code_blocks.rs b/cot-test/tests/doc_code_blocks.rs index 6a02a9b31..31df3241f 100644 --- a/cot-test/tests/doc_code_blocks.rs +++ b/cot-test/tests/doc_code_blocks.rs @@ -6,6 +6,7 @@ use std::sync::OnceLock; use comrak::arena_tree::NodeEdge; use comrak::nodes::NodeValue; use comrak::{Arena, parse_document}; +use cot_test::utils::format_code_snippet; use cot_test::{TestConfig, TestLanguage, get_test_project}; use libtest_mimic::{Arguments, Failed, Trial}; @@ -77,16 +78,18 @@ fn test_md(trials: &mut Vec, file_name: &str, file_contents: &str) { }; let lang = lang.unwrap_or_else(|err| { - let file_info = format!( - "{}:{}:{}", - file_name, node_data.sourcepos.start.line, node_data.sourcepos.start.column + let start_line = node_data.sourcepos.start.line; + let start_col = node_data.sourcepos.start.column; + + let snippet = format_code_snippet( + &code_block.literal, + file_name, + start_line, + start_col, + 5, ); - panic!( - "{} in {}\n{}", - err, - file_info, - code_block.literal.replace('\n', "\n ") - ) + + panic!("{err}\n{snippet}"); }); if let Some(runner) = TEST_RUNNERS.get().unwrap().get(&(lang, test_config)) { From 09552484374a219fcd5025d76e607af8d54d0ffb Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 30 Jul 2026 22:23:26 +0000 Subject: [PATCH 08/11] update rollback cmd to use compiled binary --- docs/databases/migrations.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index de5901a01..9505cca78 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -174,10 +174,10 @@ If Cot ever detects a cycle in your migration dependencies, it will refuse to ru ## Rolling back migrations -To undo migrations, use: +Rolling back migrations runs through your compiled project binary in the target dir, not the `cot` CLI: ```bash -cot migration rollback [--app ] [--dry-run] +./path/to/binary migration rollback [--app ] [--dry-run] ``` `` can be given a few different ways: @@ -193,7 +193,7 @@ Rollbacks aren't limited to a single app either. If another app has a migration Because rolling back can have a wider blast radius than expected once cross-app dependencies are involved, it's worth checking first with the `--dry-run` flag which shows a preview of what migrations will be undone: ```bash -cot migration rollback --dry-run +./target/debug/binary migration rollback --dry-run ``` This prints the exact rollback plan without touching your database at all, something like: From faabe19afd8123a422467d742de2eea89817118a Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 31 Jul 2026 00:54:42 +0000 Subject: [PATCH 09/11] improve docs, and fix clippy --- cot-test/src/utils.rs | 20 +++++++++++--------- docs/databases/migrations.md | 8 ++++++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/cot-test/src/utils.rs b/cot-test/src/utils.rs index dd23e78b5..d0a124175 100644 --- a/cot-test/src/utils.rs +++ b/cot-test/src/utils.rs @@ -1,5 +1,7 @@ //! Test utilities +use std::fmt::Write; + /// Formats a code snippet for an error message using the style of rustc /// diagnostics /// @@ -15,6 +17,7 @@ /// 205 | migration: m_0001_initial /// | /// ``` +#[must_use] pub fn format_code_snippet( literal: &str, file_name: &str, @@ -22,6 +25,7 @@ pub fn format_code_snippet( start_col: usize, max_lines: usize, ) -> String { + const FMT_MSG: &str = "failed to write to string buffer"; let lines: Vec<&str> = literal.lines().take(max_lines).collect(); // Width of the largest line number, for gutter alignment. @@ -29,25 +33,23 @@ pub fn format_code_snippet( let gutter_width = last_line_num.to_string().len(); let mut out = String::new(); - out.push_str(&format!( + writeln!( + out, "{:width$}--> {}:{}:{}\n", "", file_name, start_line, start_col, width = gutter_width + 1 - )); - out.push_str(&format!("{:width$} |\n", "", width = gutter_width)); + ) + .expect(FMT_MSG); + writeln!(out, "{:gutter_width$} |", "").expect(FMT_MSG); for (i, line) in lines.iter().enumerate() { let line_num = start_line + i; - out.push_str(&format!( - "{line_num:width$} | {line}\n", - width = gutter_width - )); + writeln!(out, "{line_num:gutter_width$} | {line}").expect(FMT_MSG); } - - out.push_str(&format!("{:width$} |", "", width = gutter_width)); + writeln!(out, "{:gutter_width$} |", "").expect(FMT_MSG); out } diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index 9505cca78..edbdc4c44 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -95,7 +95,7 @@ Every migration is just a unit struct that implements the [`Migration`](trait@co * **`DEPENDENCIES`**: a list of other migrations (including those in other apps) that must run before this one. See [Dependencies](#dependencies) below. * **`OPERATIONS`**: the actual list of schema changes this migration performs, applied in order. See [Operations](#operations) below. -You'll also notice a second struct below the `Migration` impl, prefixed with an underscore (`_Product` above). This is a snapshot of the model as it looked at the time this migration was generated, marked with `model_type = "migration"`. Cot uses these snapshots to work out what changed the next time you run `cot migration make`, by diffing your current models against the latest snapshot on record. You shouldn't need to touch these by hand, they exist purely so future migrations can be generated correctly. +You'll also notice a second struct below the `Migration` impl, prefixed with an underscore (`_Product` above). This is a snapshot of the model as it looked at the time this migration was generated, marked with `model_type = "migration"`. Cot uses these snapshots to work out what changed the next time you run `cot migration make`, by diffing your current models against the latest snapshot on record. It's also the struct you should reach for if you need typed access to this model inside a custom operation, See [Using models inside custom operations](#using-models-inside-custom-operations) below for more on this. . ### The `migrations.rs` module @@ -121,7 +121,7 @@ This file is regenerated in full every time you run `cot migration make` or `cot ## Operations -An [`Operation`](struct@cot::db::migrations::Operation) describes one schema change, and knows how to apply itself both [`forwards`](struct@cot::db::migrations::Operation#method.forwards) (when migrating) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) (when rolling back). You'll rarely construct these by hand since `cot migration make` does it for you, but it's useful to know the shape, especially if you're writing a custom migration. +An [`Operation`](struct@cot::db::migrations::Operation) describes one schema change, and knows how to apply itself both [`forward`](struct@cot::db::migrations::Operation#method.forwards) (when migrating) and [`backwards`](struct@cot::db::migrations::Operation#method.backwards) (when rolling back). You'll rarely construct these by hand since `cot migration make` does it for you, but it's useful to know the shape, especially if you're writing a custom migration. For example, adding a field to an existing table: @@ -161,6 +161,10 @@ const OPERATION: Operation = Operation::custom(forwards).backwards(backwards).bu The [`#[migration_op]`](attr@cot::db::migrations::migration_op) macro just takes care of the boilerplate needed to match the function signature Cot expects. `backwards` is optional. If you skip it and someone later tries to roll this migration back, Cot will simply return an error saying the backwards migration isn't implemented, rather than silently doing nothing. +#### Using models inside custom operations + +When you query a model inside `forwards` or `backwards` operation functions, use the snapshot Model (annotated with `model_type = "migration"` in the migration file), not the live one from your crate (eg. `crate::Product`). Migrations replay in order on every fresh database, so at the point this migration runs, later migrations haven't happened yet. The live model may already have fields the table doesn't have at this stage, which can fail outright, or worse, write bad data without failing at all. The snapshot always matches the table as it actually looked at this point in history. + ## Dependencies Migrations within the same app are always applied in the order their names sort in (which is why the auto-generated names are zero-padded and sequential). Across apps, or whenever ordering needs to be more explicit than that, you can add entries to `DEPENDENCIES`: From ee01684e4636d88ba3b1b0e70f9aa5555d796c7b Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 31 Jul 2026 01:04:18 +0000 Subject: [PATCH 10/11] snapshot -> migration model --- docs/databases/migrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index edbdc4c44..d5818a715 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -163,7 +163,7 @@ The [`#[migration_op]`](attr@cot::db::migrations::migration_op) macro just takes #### Using models inside custom operations -When you query a model inside `forwards` or `backwards` operation functions, use the snapshot Model (annotated with `model_type = "migration"` in the migration file), not the live one from your crate (eg. `crate::Product`). Migrations replay in order on every fresh database, so at the point this migration runs, later migrations haven't happened yet. The live model may already have fields the table doesn't have at this stage, which can fail outright, or worse, write bad data without failing at all. The snapshot always matches the table as it actually looked at this point in history. +When you query a model inside `forwards` or `backwards` operation functions, use the migration Model (annotated with `model_type = "migration"` in the migration file), not the live one from your crate (eg. `crate::Product`). Migrations replay in order on every fresh database, so at the point this migration runs, later migrations haven't happened yet. The live model may already have fields the table doesn't have at this stage, which can fail outright, or worse, write bad data without failing at all. The migration model always matches the table as it actually looked at this point in history. ## Dependencies From 01041d9731d1385aa0da0a1ad8a5904293121f09 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 1 Aug 2026 16:57:18 +0000 Subject: [PATCH 11/11] use an error struct for reusability. --- cot-test/tests/doc_code_blocks.rs | 48 ++++++++++++++++++++----------- docs/databases/migrations.md | 2 +- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/cot-test/tests/doc_code_blocks.rs b/cot-test/tests/doc_code_blocks.rs index 31df3241f..355b9847a 100644 --- a/cot-test/tests/doc_code_blocks.rs +++ b/cot-test/tests/doc_code_blocks.rs @@ -7,13 +7,27 @@ use comrak::arena_tree::NodeEdge; use comrak::nodes::NodeValue; use comrak::{Arena, parse_document}; use cot_test::utils::format_code_snippet; -use cot_test::{TestConfig, TestLanguage, get_test_project}; +use cot_test::{TestConfig, TestLanguage, TestLanguageFromStringError, get_test_project}; use libtest_mimic::{Arguments, Failed, Trial}; type TestRunner = fn(&str) -> Result<(), Failed>; static TEST_RUNNERS: OnceLock> = OnceLock::new(); +#[derive(Debug, thiserror::Error)] +#[error( + "{source}\n{}", + format_code_snippet(literal, file_name, *start_line, *start_col, 5) +)] +pub struct CodeBlockError { + #[source] + source: TestLanguageFromStringError, + file_name: String, + literal: String, + start_line: usize, + start_col: usize, +} + fn main() { let args = Arguments::from_args(); @@ -46,13 +60,19 @@ fn main() { } let contents = fs::read_to_string(&path).expect("failed to read md file"); - test_md(&mut trials, file_name, &contents); + if let Err(err) = test_md(&mut trials, file_name, &contents) { + panic!("{err}"); + } } libtest_mimic::run(&args, trials).exit(); } -fn test_md(trials: &mut Vec, file_name: &str, file_contents: &str) { +fn test_md( + trials: &mut Vec, + file_name: &str, + file_contents: &str, +) -> Result<(), CodeBlockError> { let arena = Arena::new(); let mut options = comrak::Options::default(); @@ -77,20 +97,13 @@ fn test_md(trials: &mut Vec, file_name: &str, file_contents: &str) { ) }; - let lang = lang.unwrap_or_else(|err| { - let start_line = node_data.sourcepos.start.line; - let start_col = node_data.sourcepos.start.column; - - let snippet = format_code_snippet( - &code_block.literal, - file_name, - start_line, - start_col, - 5, - ); - - panic!("{err}\n{snippet}"); - }); + let lang = lang.map_err(|source| CodeBlockError { + source, + file_name: file_name.to_string(), + literal: code_block.literal.clone(), + start_line: node_data.sourcepos.start.line, + start_col: node_data.sourcepos.start.column, + })?; if let Some(runner) = TEST_RUNNERS.get().unwrap().get(&(lang, test_config)) { let literal = if lang == TestLanguage::Rust { @@ -121,6 +134,7 @@ fn test_md(trials: &mut Vec, file_name: &str, file_contents: &str) { } } } + Ok(()) } fn clean_code(code: &str) -> String { diff --git a/docs/databases/migrations.md b/docs/databases/migrations.md index d5818a715..0bd27e536 100644 --- a/docs/databases/migrations.md +++ b/docs/databases/migrations.md @@ -19,7 +19,7 @@ Cot compares your current models against the models recorded in the most recent A few things worth knowing about how this works: * The **app name** for a migration is your crate name by default. You can override it with `--app-name` if you'd like, though in most single-crate projects you'll never need to. -* Migrations are generated per crate. If you have a Cargo workspace with multiple crates, each one gets its own `migrations` directory and its own independent history. There's currently no way for a migration in one crate to be generated against models living in another crate in the same workspace, that's on the roadmap but not there yet. +* Migrations are generated per crate. If you have a Cargo workspace with multiple crates, each one gets its own `migrations` directory and its own independent history. There's currently no way for a migration in one crate to be generated against models living in another crate in the same workspace. * By default, migrations are written to the `src/migrations/` directory inside your crate. You can point this elsewhere with `--output-dir` if needed. If you want an empty migration to hand-write yourself (useful for data migrations, or anything that isn't a simple schema change), use: