toolu-orm-connection 0.2.0

Connection pooling and driver adapters (libsql, rusqlite, Postgres) for toolu-orm
Documentation

๐Ÿ—„๏ธ toolu-orm

Schema-first Rust ORM โ€” one struct, three databases, migrations you can read.

Define a table once as a Rust struct. Get a typed query builder, a FromRow mapper, and a schema snapshot back. Diff the snapshot into a plain-SQL migration, apply it with a SHA-256-checked journal, and run the same code against libsql (local file, in-memory, or Turso), rusqlite, or Postgres. No runtime reflection, no macro-generated SQL you can't read.

crates.io docs.rs CI Docs License: MIT Rust 1.94 No unwrap

Documentation ยท Why ยท Features ยท How it works ยท Install ยท Quickstart ยท Tables ยท Queries ยท Relations ยท Migrations ยท Drivers ยท Contributing


Why toolu-orm?

Most Rust database layers make you pick a side:

  • Query builders give you type-safe SQL but leave schema evolution to you. The migration folder drifts from the structs, and nobody notices until prod.
  • Full ORMs own the schema but hide the SQL behind a runtime, a DSL, or a code generator you have to re-run and re-learn.

toolu-orm keeps the struct as the single source of truth and generates everything else from it. #[table] produces a TableDef. A SchemaRegistry of those defs is diffed against the last JSON snapshot to write the next NNNN_name.sql migration โ€” plain SQL you can read in review. A journal records each file's SHA-256 so a migration edited after it shipped fails loudly instead of silently diverging.

The same struct also hands you typed Column<T> constants, select() / insert() / update() / delete() builder factories, and an async executor that speaks ?1 to SQLite and $1 to Postgres. Swap the driver by flipping a Cargo feature; the application code does not change.

Extracted from a production backend where it drives Turso embedded replicas in the field and Postgres in the cloud, from one set of table structs.


Features

๐Ÿงฑ Schema as code #[table] turns a struct into a TableDef with primary keys, defaults, foreign keys with on_delete / on_update, strict tables, and #[index] / #[unique_index].
๐Ÿ” Diff-driven migrations run_generate diffs your registry against the last *.snapshot.json and writes numbered SQL with a --> statement-breakpoint separator. run_migrate applies pending files in one transaction each; get_status lists applied and pending.
๐Ÿ” Tamper-evident journal _journal.json stores a sha256: hash per migration. A file that changed after it was recorded stops the run with MigrateError::HashMismatch.
๐Ÿงฎ Typed columns, typed expressions Generated Column<T> constants (users::email) build Expr trees: eq / ne / in_list / not_in / is_null on every column, like on text, gt / lt / gte / lte / between on numbers, combined with .and() / .or(). Table-qualified, always quoted.
๐Ÿ—๏ธ Four builders, one executor SelectBuilder, InsertBuilder (with or_ignore / or_replace), UpdateBuilder (set / set_expr), DeleteBuilder. All share .execute(); select adds fetch_all, fetch_one, fetch_optional, count, exists.
๐ŸŒ Dialect-aware SQL to_sql_for(Dialect::Sqlite) emits ?N placeholders; Dialect::Postgres emits $N, ON CONFLICT ... DO UPDATE SET ... = EXCLUDED, and LEFT JOIN LATERAL + json_agg for relations.
๐Ÿ•ธ๏ธ Relational loads without N+1 #[derive(Relational)] with #[has_many], #[belongs_to], #[many_to_many]; RelationalQuery fetches parent + children as JSON arrays in a single statement per dialect.
๐Ÿ”Ž Full-text search #[fts5_table] (or the Fts5Table builder) declares an SQLite FTS5 virtual table with UNINDEXED columns, a free-form tokenizer, and external content. Migrations emit CREATE VIRTUAL TABLE ... USING fts5(...).
๐Ÿงฌ Enums and views #[derive(ColumnEnum)] stores a Rust enum as text; #[view(Name, pick(...))] / omit(...) generates subset structs from a table.
๐Ÿ”„ Transactions `conn.run_transaction(
๐Ÿ”Œ Three drivers, one trait DbConnection over libsql (async, Turso embedded replica with sync retry), rusqlite (sync, wrapped in spawn_blocking), and Postgres (deadpool-postgres pool, rustls TLS).
๐Ÿ›ก๏ธ Panic-free src/ Workspace-wide clippy::unwrap_used, expect_used, panic, indexing_slicing are deny. No #[allow] anywhere.

How it works

Five crates. orm-core is the foundation; every other crate depends on it, and only orm-cli depends on orm-connection.

                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                 โ”‚      toolu-orm-core     โ”‚  TableDef ยท ColumnType ยท Value ยท Expr
                 โ”‚  schema ยท snapshot ยท    โ”‚  Column<T> ยท Snapshot ยท Journal
                 โ”‚  diff ยท dialect ยท row   โ”‚  Dialect { Sqlite, Postgres }
                 โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
        โ–ผ                    โ–ผ                     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ toolu-orm-    โ”‚   โ”‚ toolu-orm-     โ”‚   โ”‚ toolu-orm-connection โ”‚
โ”‚ macros        โ”‚   โ”‚ query          โ”‚   โ”‚ Database ยท DbConnection
โ”‚ #[table]      โ”‚   โ”‚ Select/Insert/ โ”‚   โ”‚ libsql ยท rusqlite ยท   โ”‚
โ”‚ FromRow       โ”‚   โ”‚ Update/Delete  โ”‚   โ”‚ PgDatabase (pool+TLS) โ”‚
โ”‚ Relational    โ”‚   โ”‚ RelationalQueryโ”‚   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ ColumnEnum    โ”‚   โ”‚ Executor ยท tx  โ”‚              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜              โ–ผ
                                          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                          โ”‚    toolu-orm-cli      โ”‚
                                          โ”‚ run_generate          โ”‚
                                          โ”‚ run_migrate           โ”‚
                                          โ”‚ get_status            โ”‚
                                          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The migration loop in one line:

structs โ”€โ”€#[table]โ”€โ”€โ–ถ TableDef โ”€โ”€SchemaRegistryโ”€โ”€โ–ถ diff vs last snapshot โ”€โ”€โ–ถ NNNN_name.sql + snapshot.json + _journal.json

Install

The toolu-orm facade pulls in the whole stack behind one version and one feature list:

[dependencies]
toolu-orm = { version = "0.1", features = ["libsql"] }
tokio     = { version = "1", features = ["rt-multi-thread", "macros"] }

That is the whole list. It re-exports toolu_orm::core, toolu_orm::query, toolu_orm::connection and the proc macros, and the macros expand to absolute paths resolved against your Cargo.toml, so #[table] and the derives work with no other dependency and no import beyond the macro itself. toolu_orm::prelude still exists as a convenience glob; nothing requires it.

toolu-orm-cli is not re-exported by the facade. Add it as a normal dependency when you generate or apply migrations from your own binary โ€” it is a library crate with no [[bin]] of its own:

toolu-orm-cli = { version = "0.1", default-features = false, features = ["libsql"] }

If you do name toolu-orm-core directly as well, keep it on the same version as toolu-orm: they share one workspace version, and a mismatch means two different toolu_orm_core crates in the graph, whose types do not interoperate.

Depending on the crates directly

Every crate exposes the same driver features (libsql, rusqlite, postgres) and forwards them to toolu-orm-core. Enable the drivers you need on every crate you depend on so Cargo unifies them into one shape.

[dependencies]
toolu-orm-core       = { version = "0.1", default-features = false, features = ["libsql"] }
toolu-orm-macros     = { version = "0.1", features = ["libsql"] }
toolu-orm-query      = { version = "0.1", features = ["libsql"] }
toolu-orm-connection = { version = "0.1", features = ["libsql"] }
toolu-orm-cli        = { version = "0.1", default-features = false, features = ["libsql"] }
tokio                = { version = "1", features = ["rt-multi-thread", "macros"] }

For Postgres, replace "libsql" with "postgres". toolu-orm-core and toolu-orm-cli default to libsql; the other crates have no default driver.


Quickstart

Define a table, generate and apply a migration, insert, and read back โ€” against an in-memory libsql database.

[dependencies]
toolu-orm     = { version = "0.1", features = ["libsql"] }
toolu-orm-cli = { version = "0.1", default-features = false, features = ["libsql"] }
tokio         = { version = "1", features = ["rt-multi-thread", "macros"] }

toolu-orm-cli is a plain library crate despite the name โ€” it ships no binary, so run_generate, run_migrate and get_status are called from your own code (see Migrations for the usual bin/migrate.rs).

use toolu_orm::connection::Database;
use toolu_orm::core::column::{Integer, Text};
use toolu_orm::core::dialect::Dialect;
use toolu_orm::core::query_column::CommonOps;
use toolu_orm::core::schema::SchemaRegistry;
use toolu_orm::core::table::TableSchema;
use toolu_orm::{table, FromRow};

#[table(name = "users")]
pub struct UsersTable {
  #[column(primary_key)]
  pub id: Text,
  #[column(not_null)]
  pub email: Text,
  #[column(not_null, default = "unixepoch()")]
  pub created_at: Integer,
}

// The derive follows the drivers active on `toolu-orm-core`: one driver
// (libsql here) means a single `from_row`. See "Row mapping".
#[derive(FromRow)]
pub struct User {
  pub id: String,
  pub email: String,
  pub created_at: i64,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
  // 1. Schema โ†’ migration file (writes migrations/0001_init.sql + snapshot + journal)
  std::fs::create_dir_all("migrations")?;
  let registry = SchemaRegistry::from_tables(vec![UsersTable::table_def()]);
  toolu_orm_cli::generate::run_generate(&registry, "migrations", "init", Dialect::Sqlite)?;

  // 2. Connect and apply whatever is pending
  let db = Database::init_local(":memory:").await?;
  let conn = db.connect()?;
  let applied = toolu_orm_cli::migrate::run_migrate(&conn, "migrations", Dialect::Sqlite).await?;
  println!("applied {applied} migration(s)");

  // 3. Typed writes and reads through the generated builders.
  //    The builders run on the driver connection, which the wrapper exposes.
  let exec = conn.inner_conn();   // &libsql::Connection โ€” borrows `conn`, so
                                  // keep `conn` alive for as long as `exec` is used

  UsersTable::insert()
    .set(&users::id, "u_1")
    .set(&users::email, "ada@example.com")
    .execute(exec)
    .await?;

  let found: Vec<User> = UsersTable::select_for::<User>()
    .filter(users::email.eq("ada@example.com"))
    .fetch_all(exec)
    .await?;
  println!("{} user(s)", found.len());
  Ok(())
}

#[table] generated everything used above: the users companion module with one Column<T> per field, UsersTable::table_def(), and the select() / select_for::<T>() / insert() / update() / delete() factories.

Two connection surfaces show up there. DbConnection โ€” what db.connect() returns โ€” is what run_migrate and get_status take. Executor is what the query builders run on, and it is implemented for the driver's own connection type (libsql::Connection, rusqlite::Connection, tokio_postgres::Client, PgTransaction), which conn.inner_conn() hands out.


Defining tables

use toolu_orm_macros::{table, ColumnEnum};

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, ColumnEnum)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus { Pending, Running, Success, Failed }

#[table(name = "pipeline_runs", strict = true)]
#[index("idx_runs_pipeline", pipeline_id)]
#[index("idx_runs_status", status)]
pub struct PipelineRun {
  #[column(primary_key, default = "uuid4_str()")]
  pub id: Uuid,
  #[column(not_null, references = "pipelines(id)", on_delete = "cascade")]
  pub pipeline_id: Uuid,
  #[column(not_null, default = "'pending'")]
  pub status: RunStatus,
  #[column(not_null, default = "datetime('now')")]
  pub created_at: Timestamp,
}
Attribute Effect
#[table(name = "...", strict = true)] Table name; strict switches column SQL types to the SQLite / Turso STRICT set.
#[column(primary_key)] Primary key.
#[column(not_null)] NOT NULL; omit it for a nullable column.
#[column(default = "...")] Raw SQL default, e.g. "unixepoch()", "'pending'", "uuid4_str()".
#[column(references = "t(col)", on_delete = "cascade", on_update = "...")] Foreign key with referential actions.
#[column(as_text)] Store an enum or custom type as TEXT.
#[index("name", col, ...)] / #[unique_index("name", col)] Secondary indexes on the table; unique_index is how you express uniqueness.
#[view(Name, pick(a, b))] / #[view(Name, omit(c))] Generate a subset struct from the table.

Field types map to ColumnType: Text, Integer, Real, Blob, Uuid, Boolean, Timestamp, Date, Time, Json, plus Postgres-flavoured BigInt, SmallInt, Varchar(n), Serial, BigSerial, Jsonb, Numeric, Char(n), Array.

Row mapping. #[derive(FromRow)] fills REQUIRED_COLUMNS from the field names in declaration order and reads each field positionally at its own index, so select_for::<T>() picks exactly the columns T needs, in the order it decodes them. An Option<T> field decodes SQL NULL as None; anything else missing or undecodable is a DbCoreError::RowMapping naming the column's index and name, never a panic.

The derive expands to whichever shape the drivers on toolu-orm-core gave the trait, so it compiles on every combination โ€” one driver means a single from_row, two or more mean one method per driver:

Drivers active Methods the derive implements
one of libsql / rusqlite / postgres from_row
any two two of from_pg_row / from_libsql_row / from_rusqlite_row
all three all three

#[from_row(with = "f")] on a field routes the decoded value through f (FieldTy -> Result<FieldTy, E>) to normalize or reject it.

Writing the impl by hand stays supported, and is the way out when a field type the active driver cannot decode needs a conversion:

use toolu_orm_core::{error::DbCoreError, row::FromRow};

impl FromRow for User {
  const REQUIRED_COLUMNS: &'static [&'static str] = &["id", "email", "created_at"];

  fn from_row(row: &libsql::Row) -> Result<Self, DbCoreError> {
    let col = |i: i32, e: libsql::Error| DbCoreError::RowMapping(format!("col {i}: {e}"));
    Ok(Self {
      id: row.get(0).map_err(|e| col(0, e))?,
      email: row.get(1).map_err(|e| col(1, e))?,
      created_at: row.get(2).map_err(|e| col(2, e))?,
    })
  }
}

Virtual tables (SQLite FTS5)

#[fts5_table] declares a full-text index. It generates the same items as #[table] โ€” a TableSchema, typed Column<T> constants, builder factories โ€” but the TableDef carries TableKind::Virtual { module: "fts5", args }:

use toolu_orm_macros::fts5_table;

#[fts5_table(name = "memory_fts", tokenize = "porter unicode61 remove_diacritics 2")]
pub struct MemoryFts {
  #[column(unindexed)]
  pub memory_id: Text,
  pub body: Text,
  pub tags: Text,
}
CREATE VIRTUAL TABLE IF NOT EXISTS "memory_fts" USING fts5("memory_id" UNINDEXED,
  "body", "tags", tokenize = 'porter unicode61 remove_diacritics 2');

name is required; tokenize, prefix, content, content_rowid, columnsize and detail are passed through to the module verbatim, so a tokenizer this crate has never heard of still works. #[column(unindexed)] appends UNINDEXED: the value is stored and readable but not searchable. Fts5Table is the same thing without the macro, for a TableDef built at runtime.

SQLite cannot ALTER a virtual table, so run_generate refuses any in-place change to one โ€” a new column, a different tokenizer, a switched module โ€” with DbCoreError::VirtualTableChange and writes no migration; drop and recreate it instead. Creating, dropping and renaming work as usual. On Postgres the table is skipped with a comment naming it. Other modules (vec0, rtree) need no new code: build the TableDef with TableKind::virtual_table(module, args).


Query builders

The snippets below name the crates directly (toolu_orm_core::โ€ฆ, toolu_orm_query::โ€ฆ); through the facade the same items are toolu_orm::core::โ€ฆ and toolu_orm::query::โ€ฆ.

Every builder renders with to_sql() (current dialect) or to_sql_for(Dialect::โ€ฆ) and returns (String, Vec<Value>). Column references are always table-qualified and quoted. The comparison methods come from three traits in toolu_orm_core::query_column: CommonOps (eq, ne, in_list, not_in, is_null, is_not_null), TextOps (like), and NumericOps (gt, lt, gte, lte, between).

use toolu_orm_core::query_column::{CommonOps, NumericOps};
use toolu_orm_query::{delete::DeleteBuilder, insert::InsertBuilder, select::SelectBuilder, update::UpdateBuilder};

// SELECT with filters, join, ordering, paging
let (sql, params) = SelectBuilder::new("users")
  .columns_raw(&["id", "email"])
  .filter(users::org_id.eq("org123"))
  .filter(users::created_at.gt(0i32))
  .join("pipelines", users::id.equals(&pipelines::user_id))   // INNER JOIN; left_join() too
  .order_by(users::created_at.desc())
  .limit(10)
  .offset(0)
  .to_sql_for(Dialect::Sqlite);
// SELECT "id", "email" FROM "users"
//   INNER JOIN "pipelines" ON "users"."id" = "pipelines"."user_id"
//   WHERE "users"."org_id" = ?1 AND "users"."created_at" > ?2
//   ORDER BY "users"."created_at" DESC LIMIT ?3 OFFSET ?4

// INSERT, with upsert flavours that render per dialect
InsertBuilder::new("seeds").or_ignore().set(&seeds::id, "seed-1").to_sql_for(Dialect::Postgres);
// INSERT INTO "seeds" ("id") VALUES ($1) ON CONFLICT DO NOTHING
InsertBuilder::new("run_status").or_replace().set(&run_status::run_id, "run-1").set(&run_status::status, "running")
  .to_sql_for(Dialect::Postgres);
// ... ON CONFLICT ("run_id") DO UPDATE SET "status" = EXCLUDED."status"

// UPDATE with a bound value and a raw SQL expression
UpdateBuilder::new("users").set(&users::email, "new@example.com").set_expr(&users::updated_at, "unixepoch()")
  .filter(users::id.eq("user-1")).to_sql_for(Dialect::Sqlite);
// UPDATE "users" SET "email" = ?1, "updated_at" = unixepoch() WHERE "users"."id" = ?2

// DELETE
DeleteBuilder::new("users").filter(users::id.eq("user-1")).to_sql_for(Dialect::Postgres);
// DELETE FROM "users" WHERE "users"."id" = $1

Executing. All four builders share .execute(exec) -> u64, where exec is the driver connection (&libsql::Connection, &rusqlite::Connection, &tokio_postgres::Client, or a transaction) โ€” not the DbConnection wrapper. Select adds:

let users: Vec<User> = SelectBuilder::new("users").columns_raw(&["id", "email", "created_at"]).fetch_all(exec).await?;
let one: User        = UsersTable::select_for::<User>().filter(users::id.eq("u_1")).fetch_one(exec).await?;   // QueryError::NotFound if empty
let maybe: Option<User> = UsersTable::select_for::<User>().filter(users::id.eq("nope")).fetch_optional(exec).await?;
let n: i64           = UsersTable::select().count(exec).await?;
let any: bool        = UsersTable::select().exists(exec).await?;

On the rusqlite driver these are synchronous: same names, no .await.

Also available: to_count_sql_for, to_exists_sql_for, SelectBuilder::raw().column_expr(expr, alias), and columns_typed(&[&dyn ColumnRef]).

Transactions. Anything that errors inside the closure rolls the whole block back.

use toolu_orm_query::transaction::TransactionExt;

// `conn` is a libsql::Connection โ€” TransactionExt is implemented on the driver type.
conn.run_transaction(|tx| async move {
  InsertBuilder::new("users").set(&users::id, "tx1").set(&users::email, "tx@example.com").execute(&tx).await?;
  UpdateBuilder::new("counters").set_expr(&counters::users, "users + 1").execute(&tx).await?;
  Ok(())
}).await?;

Relations

Declare the shape you want back; the query is one statement per dialect.

use toolu_orm_macros::Relational;

#[derive(Relational)]
#[relational(table = "users")]
struct UserWithPosts {
  pub id: String,
  pub name: String,
  #[has_many(table = "posts", foreign_key = "author_id", columns = ["id", "title"])]
  pub posts: Vec<PostRow>,
}
// #[belongs_to(...)]    โ†’ field is Option<T>, same keys
// #[many_to_many(...)]  โ†’ adds through = "post_tags", local_key = "post_id"

The typed builder tracks the result tuple at compile time:

use toolu_orm_query::relational_builder::RelationalQuery;

let q = RelationalQuery::<(UserRow,)>::new("users", &["id", "name"])
  .with_many::<PostRow>("posts", "posts", "id", "author_id", &["id", "title"])
  .with_one::<ProfileRow>("profile", "profiles", "id", "user_id", &["id", "bio"]);
let _: RelationalQuery<(UserRow, Vec<PostRow>, Option<ProfileRow>)> = q;
  • SQLite renders correlated subqueries with json_group_array.
  • Postgres renders LEFT JOIN LATERAL with json_agg / json_build_array.

An untyped RelationalSelectBuilder exposes the same with_many / with_one plus to_sql_sqlite() / to_sql_postgres() when you only want the SQL.


Migrations

use toolu_orm_cli::{generate::run_generate, status::get_status};
use toolu_orm_cli::migrate::{run_migrate, run_migrate_embedded};

let wrote = run_generate(&registry, "migrations", "add_posts", Dialect::Postgres)?;
//  โ†’ Some("0002_add_posts.sql"), or None when the schema did not change

let applied = run_migrate(&conn, "migrations", Dialect::Postgres).await?;   // u32 files applied
// โ€ฆor, with the SQL compiled into the binary:
let applied = run_migrate_embedded(&conn, MIGRATIONS, Dialect::Postgres).await?;

let status = get_status(&conn, "migrations", Dialect::Postgres).await?;
println!("applied: {:?}, pending: {:?}", status.applied, status.pending);

What lands on disk:

migrations/
โ”œโ”€โ”€ _journal.json                # order + "sha256:โ€ฆ" per file
โ”œโ”€โ”€ 0001_init.sql
โ”œโ”€โ”€ 0001_init.snapshot.json      # schema state after this migration
โ”œโ”€โ”€ 0002_add_posts.sql
โ””โ”€โ”€ 0002_add_posts.snapshot.json
  • Files apply in name order; the applied set is tracked in a _migrations table on the target database.
  • A file holding several statements separates them with --> statement-breakpoint. Each file runs inside BEGIN / COMMIT.
  • The journal hash is verified before a file runs. Edit a shipped migration and run_migrate stops with MigrateError::HashMismatch.
  • Shipping a single binary with no migrations directory on the target machine? Bake the SQL in with include_str! and apply it with run_migrate_embedded(&conn, MIGRATIONS, dialect), where MIGRATIONS is a &[EmbeddedMigration] of name / sql / hash. Same hashes, same one-transaction-per-migration, and a database is free to move between the two sources.
  • Adopting toolu-orm on a database that already has the schema? Baseline it with mark_applied(&conn, "migrations", &["0001_init.sql"], dialect) โ€” or mark_applied_through(&conn, "migrations", "0016_add_tags.sql", dialect) โ€” to record those journal entries (with their journal hashes) without executing them, so the next run_migrate starts from the first one you did not baseline.
  • Snapshots are plain JSON (version, dialect, id, prev_id, tables, enums, meta), so a schema diff is reviewable in the PR alongside the SQL.

Drivers

Feature Backing crate Mode Open with
libsql libsql async; local file, :memory:, or Turso embedded replica Database::init_local(path) ยท Database::init_remote(RemoteConfig)
rusqlite rusqlite (bundled) sync natively (DbConnectionBlocking), wrapped in spawn_blocking for the async DbConnection RusqliteConnection::from_connection(conn) (no runtime) ยท ::open(path) ยท ::open_in_memory()
postgres tokio-postgres + deadpool async pool, rustls TLS PgDatabase::init(&PgConfig) then .connect()
// Turso embedded replica: local file kept in sync with the remote
let db = Database::init_remote(RemoteConfig {
  replica_path: "data/app.db".into(),
  url: "libsql://my-db.turso.io".into(),
  auth_token: std::env::var("TURSO_TOKEN")?,
  sync_interval_secs: 5,
  max_sync_attempts: 5,
}).await?;

// Postgres pool
let pg = PgDatabase::init(&PgConfig {
  host: "localhost".into(), port: 5432,
  user: "app".into(), password: std::env::var("PGPASSWORD")?, dbname: "app".into(),
  max_connections: 10, ssl: true,
}).await?;
let conn = pg.connect().await?;

All backends implement DbConnection (execute_sql, query_map<T: FromRow>, execute_batch), which is what run_migrate and get_status accept.


Contributing

Read CLAUDE.md first: it holds the workspace map and the binding rules. The short version:

  1. No .unwrap(), .expect(), panic!, unreachable!, or [] indexing in src/. Propagate with ? / ok_or. Tests may.
  2. No #[allow] / #[expect]. Fix the warning.
  3. No #[cfg(test)] in src/; tests live in each crate's tests/.
  4. โ‰ค 250 lines per file. Over that, split into a folder module whose mod.rs holds only mod, pub use, and //! docs.
  5. One concern per file. No utils.rs / helpers.rs / common.rs.
  6. cargo nextest run, never cargo test.

The quality gate is what CI runs: four feature lanes plus a docs check. Every test executes against a real database (in-memory libsql, in-memory rusqlite, or a live Postgres), so start the test Postgres first:

docker compose -f docker-compose.test.yaml up -d --wait   # postgres:16 on localhost:5434
export TEST_DB_PORT=5434                                   # for_test() defaults to 5433

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run --workspace
cargo clippy -p toolu-orm -p toolu-orm-core -p toolu-orm-macros -p toolu-orm-query -p toolu-orm-connection -p toolu-orm-cli --features postgres --all-targets -- -D warnings
cargo nextest run -p toolu-orm -p toolu-orm-core -p toolu-orm-macros -p toolu-orm-query -p toolu-orm-connection -p toolu-orm-cli --features postgres
cargo clippy -p toolu-orm-query --features libsql --all-targets -- -D warnings
cargo nextest run -p toolu-orm-query --features libsql
cargo clippy -p toolu-orm-query --features rusqlite --all-targets -- -D warnings
cargo nextest run -p toolu-orm-query --features rusqlite
cargo clippy -p toolu-orm-connection --features rusqlite --all-targets -- -D warnings
cargo nextest run -p toolu-orm-connection --features rusqlite
bash scripts/check-derive-matrix.sh
bash scripts/check-scenario-docs.sh

TEST_DB_HOST, TEST_DB_PORT, TEST_DB_USER, and TEST_DB_PASSWORD point the Postgres suites at another server. Each feature scenario is documented in docs/scenarios/ with the tests that prove it on every driver; the docs check fails when a page and its tests drift apart, so update the page with the test. Use a Conventional Commits subject (feat(query): add fetch_optional).

Releases

Releases are automated with release-plz. You do not bump versions or tag by hand.

  • Merge Conventional Commits to main. release-plz maintains one release PR that bumps the shared workspace version and rewrites CHANGELOG.md.
  • Merge that PR to cut the release: the five crates publish to crates.io in dependency order, then a single vX.Y.Z tag and GitHub Release are created.

License

MIT ยฉ Falconiere Barbosa