Skip to content

v1.0.0 β€” Loco is stable

Latest

Choose a tag to compare

@jondot jondot released this 29 Jul 10:07
29b492e

1.0.0 - 2026-07-25

1.0.0 is the first stable Loco release β€” a single, intentionally-breaking
milestone. Its headline is the move to Sea-ORM 2.0, alongside first-class
LLM/agent support, priority queues, a broad dependency modernization, and a deep
hardening pass across the queue, storage, config, error, remote-IP, and
middleware subsystems. Follow the step-by-step
0.16 β†’ 1.0 upgrade guide.

Breaking Changes

  • Sea-ORM 2.0 + sqlx 0.9. Bump sea-orm/sea-orm-migration to 2.0
    (app + migration crate), direct sqlx to 0.9, update the Sea-ORM CLI, and
    regenerate entities. Raw-Statement calls gain a _raw suffix; runtime SQL
    strings need AssertSqlSafe. MSRV is 1.94 (sea-orm 2.0.0 declares it).
    (Adopted from the SeaQL fork and
    #1698.)
  • Generated primary/foreign keys are now 64-bit (BIGINT / i64). Also the
    int/unsigned field types generate 64-bit columns. Only affects newly
    generated code; existing tables are untouched.
  • Priority queues β€” Redis backend change. The Redis worker moved from Lists
    to Sorted Sets (ZSET) to support priority; drain existing Redis queues before
    upgrading
    . Postgres/SQLite auto-migrate a priority column (no action).
    (#1693)
  • Worker::perform_later() returns the job ID (Result<String>), and
    Queue::enqueue() returns Result<Option<String>>. Existing
    perform_later(...).await?; keeps working. (#1624, fixes #1623)
  • PageResponse<T> exposes meta: PagerMeta instead of flat
    total_pages/total_items (also carries page/page_size). (#1685, fixes #1683)
  • View engine: use engines::TeraView::build_with_post_process(...) instead
    of TeraView::build()?.post_process(...) in after_routes.
  • Dependency majors: thiserror 1β†’2, tower 0.4β†’0.5, heckβ†’0.5,
    byte-unit 4→5, ipnetwork 0.20→0.21, strum→0.27, redis 0.31→1,
    bb8-redis→0.26, opendal 0.54→0.57; serde_yaml→serde_yaml_ng.
    Transitive for most apps.
  • Removed the dead loco-cli crate (superseded by loco-new, the published
    loco binary).
  • ExtraDbInitializer removed; use MultiDbInitializer. The
    single-extra-connection initializer (initializers.extra_db, which layered a
    bare Extension<DatabaseConnection>) is gone. Use MultiDbInitializer with a
    one-entry initializers.multi_db map instead, and extract the connection with
    Extension<MultiDb> + multi_db.get("<name>"). This collapses two
    near-identical initializers into one named-connections abstraction.
  • AppContext is now #[non_exhaustive]. Construct it with
    AppContext::builder(environment, db, config) (or builder(environment, config) without the with-db feature) followed by optional
    .queue_provider(..)/.mailer(..)/.storage(..)/.cache(..)/.shared_store(..)
    and .build(). Direct struct-literal construction and exhaustive pattern
    matches on AppContext from outside the crate no longer compile; field
    access (ctx.db, ctx.config, State/FromRef extraction) is unchanged.
    This makes future context fields non-breaking to add.
  • Storage MirrorStrategy and BackupStrategy merged into ReplicatedStrategy.
    The two strategies were the same primary-plus-secondaries replication engine;
    they are now one storage::strategies::replicated::ReplicatedStrategy with a
    single FailurePolicy enum. Migrate: MirrorStrategy::new(p, s, MirrorAll) β†’
    ReplicatedStrategy::mirror(p, s, FailurePolicy::FailIfAny);
    BackupStrategy::new(p, s, BackupAll) β†’ ReplicatedStrategy::backup(p, s, FailurePolicy::FailIfAny). Old FailureMode maps: AllowMirrorFailure/
    AllowBackupFailure β†’ AllowAll, AtLeastOneFailure β†’ AllowSingleFailure,
    CountFailure(n) β†’ FailAtFailures(n). Secondary writes for the former
    backup strategy now run concurrently (previously sequential); the collected
    errors and failure decision are unchanged.
  • Local storage driver no longer defaults its root to /.
    storage::drivers::local::new() previously rooted the filesystem store at
    /, so any key β€” including one derived from user input β€” resolved against the
    whole disk (e.g. downloading key etc/passwd read /etc/passwd). It now roots
    at the current working directory. Apps that relied on absolute-path keys should
    switch to local::new_with_prefix("/your/root") to opt back into an explicit
    absolute root.
  • Background queue reworked into a QueueProvider adapter interface. The
    bgworker::Queue enum (Postgres/Sqlite/Redis/None) is now a newtype
    over Arc<dyn QueueProvider>, so backends are pluggable (implement
    QueueProvider and wrap with Queue::from_provider). All existing methods
    (enqueue, register, run, ping, cancel_jobs, clear_by_status,
    requeue, …) keep the same signatures and behavior. Only two source-level
    changes affect callers: construct a no-op queue with Queue::empty() instead
    of Queue::None, and code that pattern-matched the enum variants (e.g.
    Queue::Postgres(pool, ..) to reach the raw pool) no longer compiles β€” use the
    provider methods instead.
  • Fallback middleware defaults to 404. When the built-in fallback is
    enabled without an explicit code, it now returns 404 Not Found (matching
    its docs and the bundled not-found page) instead of 200 OK. Apps that relied
    on the enabled fallback returning 200 must set code: 200 explicitly. The
    file-based fallback is unaffected (ServeFile reports its own status).
  • {env}.local.yaml now deep-merges over {env}.yaml. Previously the first
    existing file won and the other was ignored, so a .local.yaml had to restate
    the whole config. Both files are now layered with local precedence: mappings
    merge recursively; scalars and sequences in local replace the base value
    (sequences are not concatenated). Base keys now persist unless explicitly
    overridden.
  • More accurate HTTP status codes for errors. IntoResponse for Error
    previously collapsed ~28 of 35 variants to 500. Model(EntityNotFound) now
    returns 404, Model(EntityAlreadyExists) returns 409, model validation
    and form-body rejections return 4xx (matching JSON rejections) instead of
    500. Genuinely-internal errors still return a generic 500. Handlers that
    asserted on the old 500s will observe the corrected codes.
  • JWT::algorithm() restricted to the HMAC family. It now takes a new
    loco_rs::auth::jwt::JWTAlgorithm enum (HS256/HS384/HS512) instead of
    jsonwebtoken::Algorithm. Asymmetric algorithms β€” which could never work with
    Loco's shared base64 secret and silently produced broken tokens β€” are no longer
    representable.
  • remote_ip middleware rebuilt on axum-client-ip; trusted_proxies
    removed.
    Previously this middleware walked X-Forwarded-For right-to-left,
    skipping any address in a configurable trusted_proxies CIDR list (or a
    built-in RFC-1918 + loopback list), so it could see through a chain of one or
    more trusted proxies. It now trusts exactly one configured source
    (source: ClientIpSource, default RightmostXForwardedFor) and does no
    CIDR filtering β€” for the default it takes the last comma-separated value of the
    last X-Forwarded-For header verbatim, private or not. Single reverse-proxy
    deployments are unaffected. Multi-hop topologies (CDN β†’ LB β†’ ingress) must
    now configure their innermost hop to set the client IP (e.g. nginx
    set_real_ip_from/real_ip_recursive), or point source at a provider header
    (CfConnectingIp, CloudFrontViewerAddress, XRealIp, ConnectInfo, …).

    Note: an old config's trusted_proxies: key is silently ignored (unknown
    field), so review remote_ip before upgrading β€” this is a silent
    security-relevant behavior change, not a load error. The RemoteIP extractor
    and its Display output are unchanged.
  • auth_jwt feature renamed to auth. Update features = ["auth_jwt"] β†’ ["auth"]
    (it gates JWT auth and the ApiToken extractor, as before).
  • Background-queue features collapsed. bg_pg/bg_sqlt β†’ worker
    (Postgres+SQLite; free once sqlx is compiled), bg_redis β†’ worker_redis
    (adds dep:redis). default now has worker (not Redis). A Redis queue needs
    worker_redis; the queue backend is selected at runtime by queue.kind.
  • Mailer Template::new(dir) now returns Result (call Template::new(dir)?).
    Email templates render through a full Tera instance so they support inheritance
    and shared templates. Standard usage via Mailer::mail_template is unchanged.
    (#1694)
  • Vars::cli_arg returns Result<&str> (was Result<&String>). Callers that
    relied on &String (e.g. .clone() into a String) should use .to_owned().
    (#1732)

Added

  • First-class LLM / agent support. Root AGENTS.md teaches agents to build
    Loco apps; llms.txt / llms-full.txt are served from the site
    (llmstxt.org). Every loco new app ships an app-level AGENTS.md.
  • Priority queues with Worker::perform_later_with_priority(...); mailer
    jobs default to priority 100. (#1693)
  • Mailer implicit TLS (SMTPS / port 465) via mailer.smtp.tls. (#1774, fixes #1773)
  • Run the scheduler without a worker β€” --scheduler flag +
    StartMode::ServerAndScheduler/WorkerAndScheduler. (#1742, fixes #1737)
  • Email headers support in the mailer (#1700).
  • Multi-recipient emails. Mailer::mail_multi / mail_template_multi and the
    MultiEmail / MultiArgs types send one email to multiple To/CC/BCC
    recipients (processed by a dedicated MultiMailerWorker). (#1764)
  • Email template inheritance & shared templates. Mailer templates support
    Tera {% extends %} / {% block %} and can share a common layout via
    Mailer::mail_template_with_shared / Template::new_with_shared. loco generate mailer now scaffolds a src/mailers/shared/ base layout that the welcome
    template extends. (#1694)
  • "Create user" task (#1670).
  • UuidUniqWithDefault and UuidWithDefault types (#1642).
  • Allow overriding a secure header (#1659).
  • Mailer::deliver_now / mail_template_now for synchronous sends.
    Complements Mailer::mail/mail_template (which enqueue via the background
    worker, like Rails deliver_later) with an inline send that bypasses the
    queue (Rails deliver_now).
  • MiddlewareStackExt for surgical middleware-stack edits. Inside
    Hooks::middlewares, tweak the default stack instead of rebuilding it:
    stack.insert_before("cors", ..), .insert_after(..), .replace(..), and
    .delete("logger") β€” matched by middleware name (Rails'
    config.middleware.insert_before/delete). Available via the prelude.
  • Optional JWT extraction. JWT now implements OptionalFromRequestParts,
    so a handler can take Option<JWT> to serve authenticated and anonymous
    callers from one endpoint (Some when a valid token is present, None
    otherwise).
  • Ergonomic verb-explicit route methods. Routes now has get/post/
    put/delete/patch/head/options/trace builder methods β€”
    Routes::new().get("/ping", ping) alongside the existing
    .add("/ping", get(ping)). They record the HTTP verb directly (exact
    cargo loco routes output without relying on the debug-format regex).
    Purely additive; add is unchanged.
  • Opt-in background-job reaper (visibility timeout). Each queue backend's
    config accepts a reaper: { age_minutes, interval_seconds } block. When set,
    the worker periodically requeues jobs stranded in Processing (e.g. by a
    crashed worker) back to Queued, instead of requiring a manual
    cargo loco jobs requeue. Disabled by default β€” existing behavior is unchanged.
  • TLS to managed Postgres and Redis. Postgres TLS works via the connection
    URL (sslmode=require, sslrootcert=...) with no feature flag, and a new
    redis_tls feature enables rediss:// for both the queue and cache Redis
    backends (webpki roots, pure-Rust ring provider β€” no C toolchain). New
    how-to: "Connect to Postgres and Redis over TLS". (#1191, #1341)
  • Typed, streaming db::dump::<A>() β€” counterpart to db::seed::<A>() that
    streams rows through their entity Model straight to disk (memory bounded to
    a single row) with full type fidelity. New Hooks::dump (default dumps every
    table; override it to call db::dump per entity) backs cargo loco db seed --dump. (#1691)
  • logger::init_layer / logger::init_env_filter are now public building
    blocks, so an app overriding Hooks::init_logger can reuse Loco's formatting
    and filter policy while adding its own layers (e.g. tracing-flame, OTLP).
    (#1753)

Changed

  • Wrap TeraView in Arc to reduce runtime memory usage (#1703).
  • Refactor users model to reuse find_by_api_key in Authenticable (#1706).
  • Split error detail generic parameters (#1709).
  • Update loco-new for the new Rhai version (#1704).
  • Replaced hand-rolled Cargo.lock parsing with the cargo-lock crate; retired
    duct_sh.
  • Error β†’ HTTP-status mapping is now exhaustive. The IntoResponse for Error match dropped its trailing _ => 500 wildcard: every variant (and every
    nested ModelError variant) is now classified explicitly, so adding a new
    error variant is a compile error until its status is chosen β€” it can no longer
    silently default to 500. The Error enum is also reorganized into
    client-facing vs internal/infra regions. Behavior is unchanged (all infra
    errors still map to 500); no variant was renamed, so existing code is
    unaffected.
  • Rust edition 2024. loco-rs, loco-gen, xtask, and the loco new-app
    generator now compile on edition 2024 (MSRV floor unchanged at 1.94; edition
    2024 needs β‰₯ 1.85). Editions are per-crate, so apps depending on Loco need not
    change. Newly generated apps stay on edition 2021 for now.
  • Deduplicated the Postgres and SQLite background-queue providers: the shared
    Job/JobRegistry/RunOpts now live in one module behind a Driver trait
    (internal refactor, no behavior or API-path change).
  • In-memory cache now uses moka::future::Cache instead of wrapping the
    synchronous cache behind #[async_trait] (removes a sync-behind-async smell;
    no API change).
  • Cookie token extraction now uses axum_extra's Cookie::value() instead of
    hand-parsing the cookie string (byte-identical behavior).
  • Internal de-duplication pass (no public-API-path or behavior change unless
    noted): the response helpers in format are now single-sourced through
    RenderBuilder; the JWT/JWTWithUser extractors share one validate/decode
    helper; the six validate extractors are generated from shared decoder fns +
    two error-tier macros; the byte-identical Job struct is shared across the
    SQL and Redis queue backends; the twin cli::main functions share one
    dispatch_common; and duplicate env-var name constants were removed.
  • format's two response paths were converged onto axum's canonical behavior:
    RenderBuilder::json and RenderBuilder::redirect_with_header_key are now
    infallible with respect to bad input (they return a 500 response, matching
    axum::Json / axum::response::Redirect) instead of returning Err.

Fixed

  • db seed --dump datetime round-trip on SQLite. Loco's timestamptz columns
    default to CURRENT_TIMESTAMP, which SQLite stores as space-separated text
    ("YYYY-MM-DD HH:MM:SS"); dumps captured that verbatim and then failed
    chrono's RFC3339 parse on re-seed (Json("premature end of input")). Dumps now
    normalize such datetimes to RFC3339 (already-RFC3339 text is untouched).
    (#1736, #1691)
  • cargo fmt error in loco-new (#1669).
  • UUID pattern in form field generation (#1665).
  • Clippy warnings for recent Rust (#1705).
  • Add tests for the auth extractor (#1671).
  • Postgres/SQLite queue backends now behave consistently. Two divergences
    between the Postgres and SQLite job backends are fixed: (1) enqueue on
    Postgres previously swallowed a tag-serialization error (storing tags = null); it now propagates the error like SQLite. (2) complete_job without a
    repeat interval on Postgres stamped run_at = NOW() on the completed row while
    SQLite left it untouched; Postgres now leaves run_at as-is, matching SQLite
    (the interval path still reschedules run_at on both). The shared to_job row
    mapper is now single-sourced across both backends.
  • Storage mirror fan-out no longer stops at the first failing secondary.
    rename, copy, and upload_stream checked the failure mode inside the
    secondary loop and returned early on the first failure, silently leaving later
    mirrors stale (upload/delete were already correct). All five mutating
    methods now share one helper that attempts every secondary (concurrently) and
    applies the failure mode once.
  • Postgres BOOLEAN columns are no longer dropped from dump_tables. The
    decode probe chain had no bool arm, so PG booleans (which don't fall back to
    the numeric arms like SQLite's integer-backed booleans) were silently omitted.
  • Hooks::on_shutdown now runs in worker-only start modes. WorkerOnly and
    WorkerAndScheduler bypassed H::serve (the hook's only caller); the shutdown
    hook is now invoked on their shutdown path too.
  • Postgres admin/maintenance URI is derived with the url crate. Building it
    via db_uri.replace(db_name, "/postgres") corrupted the URI when the database
    name also appeared in the host or credentials.
  • Foreign-key names are normalized consistently. reference_id received a
    normalized table name in create_table but raw names in
    add_reference/remove_reference, so irregular plurals produced mismatched FK
    column/constraint names between creation and later add/remove.
  • ViewEngine extractor now rejects gracefully (HTTP 500) when the opt-in Tera
    layer is absent, instead of declaring Infallible and then panicking.
  • cargo loco routes lists every HTTP verb of a multi-method route (route
    introspection previously reported only the first).
  • Removed a fossilized 3-second sleep on every Redis queue boot (a leaked
    test-isolation artifact; Postgres/SQLite had no equivalent).
  • Password redaction in test snapshots (cleanup_user_model) now targets the
    quoted value precisely; the previous pattern had a degenerate quantifier that
    swallowed the field following password.
  • Postgres test-database cleanup now completes synchronously (a joined worker
    thread) instead of a fire-and-forget task, so parallel test runs no longer
    leak databases; PostgresTest also builds its connection strings with the
    url crate rather than a corruption-prone substring replace.
  • RenderBuilder::template now threads the builder's chained status/header/
    etag/cookies through to the response; it previously delegated to the free
    html() and silently dropped them.
  • llms.txt: two Core concepts links pointed at doc pages that don't exist
    (the-app/configuration/, the-app/testing/); repointed to the sections that
    actually document them. A new cargo xtask llms-check CI step now verifies the
    curated LLM docs against the docs tree so these links can't drift silently.

Removed

  • Error enum narrowing. Removed four low-value/dependency-leaking variants
    that all mapped to HTTP 500 and were never matched: Error::EnvVar,
    Error::SemVer, Error::TaskJoinError, and Error::Hash (hashing errors now
    surface as Error::Message). Error remains #[non_exhaustive], so exhaustive
    matches already require a wildcard arm and are unaffected.
  • Deleted shipped-but-dead code: the never-compiled
    controller/middleware/_archive/content_etag.rs module and a commented-out
    block of backtrace-blocklist regexes.