Skip to content

Loco 1.1.0

Latest

Choose a tag to compare

@jondot jondot released this 16 Aug 07:21
· 12 commits to master since this release
ba726cc

1.0 went out a few weeks ago and people started actually building on it. That's
the useful part β€” you find out fast which of your ideas survive contact with
real apps.

Read the announcement β†’

What's new

  • Tera 2 for views and mailers. For most apps this is one line: bump
    fluent-templates from 0.13 to 0.15. Your templates don't need
    rewriting unless they use {% macro %}, {% import %} or v.0 β€” Loco's
    generated ones use none of those.
  • Deploy to Lambda β€” cargo loco generate deployment lambda. Loco's
    router is a tower::Service and so is the Lambda runtime, so your app runs
    unchanged.
  • --no-auth on scaffold, --auth on generate controller. Scaffolded
    routes took a JWT extractor on all five handlers with no way out and nothing
    saying so.
  • cargo loco jobs retry β€” a failed job used to be terminal.
  • Storage grew exists, list and stat; generated config files are
    valid YAML.

Two queue fixes, both now covered by tests that fail without them: on SQLite,
queue.dangerously_flush: true could leave clear unable to hand out jobs; on
Redis, completed and failed jobs were invisible to jobs dump, jobs purge
and the other operator commands.

Upgrading

The upgrade guide has a prompt you can
paste straight into Claude Code or any coding agent β€” it covers the whole
1.0 β†’ 1.1 change surface and applies only what your app actually uses.


Moves the template engine to Tera 2, makes configuration files valid YAML,
opens up the storage API, and adds an AWS Lambda deployment target.

Upgrading: for most apps this is a one-line change β€” bump
fluent-templates from 0.13 to 0.15 in Cargo.toml. That crate supplies
the t() i18n function in generated apps and pinned Tera 1; 0.15 moved to
Tera 2. Verified by generating an app and compiling it with no other edit.

Beyond that, only apps that register their own Tera filters or functions
need work (see the breaking note below), and only templates using
{% macro %}/{% import %}, v.0 array access, or relying on undefined
variables rendering empty need editing β€” Tera 2 replaced macros with
components, requires v[0], and errors on undefined variables.

That last one applies to mailer templates as well as views: they render
through the same Tera 2 instance, so a mail template referencing an optional
field that is sometimes absent now fails at send time where Tera 1 rendered
an empty string. Add | default(value="") to those references.

Breaking

  • Scaffolded list endpoints changed their JSON contract, and now use the
    framework's own pagination instead of a second, parallel one. The scaffold
    had its own ListParams, its own paginator arithmetic, and an envelope with
    no total_pages β€” while query::PaginationQuery + query::paginate already
    existed and carried it. The generated handler is now four lines over
    query::paginate, and Page<T> (src/dtos/common.rs) is built by
    Page::from_query.

    On the wire: per_page β†’ page_size, total β†’ total_items, and
    total_pages is added β€” the metadata field names are PagerMeta's, so an
    app has one pagination vocabulary whichever envelope a handler returns. The
    query parameter is likewise page_size. Existing apps keep the code they
    already generated; regenerating, or updating by hand, is a rename plus one
    new field. The typed frontend's bindings/Page.ts regenerates from ts-rs.

  • QueueProvider gains a required retry_failed method. Custom queue
    provider implementations must implement it; all three built-in drivers do.
    (Queue::retry_failed is the inherent forwarding method on the handle and
    requires nothing of anyone.)

  • Storage StoreDriver / StorageStrategy gain required list and stat
    methods
    (and StorageStrategy also requires exists). Custom driver and
    strategy implementations must implement them. Built-in drivers/strategies
    already do. The Storage facade exposes matching exists / list / stat
    (and *_with_policy) APIs alongside the existing upload/download/delete
    surface. Under ReplicatedStrategy in mirror mode, exists and list treat
    a miss (false / []) the way the other reads treat an error and fall back
    to secondaries; backup mode stays primary-only.
    (#1805)

  • The template engine is now Tera 2. tera::Tera appears in Loco's public
    API (PostProcessFn, HotReloadingTeraEngine::engine, TeraView::tera,
    Error::Tera, register_filters), so custom filters and functions must move
    to Tera 2 signatures: filters now take (Arg, Kwargs, &State) over Tera's own
    Value rather than (&Value, &HashMap) over serde_json::Value.

    Loco absorbs the rest. Tera 2 dropped get_env β€” which every Loco config
    depends on β€” so Loco registers its own with Tera 1 semantics. View loading
    moved to tera::load_from_glob, which yields the same template names Tera 1
    produced, so existing render("home/hello.html") calls are unaffected.

  • Newly generated apps require their production secrets to be set. The
    generated config/production.yaml takes no defaults for secrets or
    addresses, so a missing one stops the app at startup with the variable's
    name rather than falling back to a development value. Always required:
    DATABASE_URL, JWT_SECRET, HOST. Required too, when the app was
    generated with the corresponding component: REDIS_URL / QUEUE_URL for a
    Redis or database-backed queue, and MAILER_HOST / MAILER_USER /
    MAILER_PASSWORD for a mailer. Existing apps are unaffected β€” their config
    files are their own. See Fixed below.

  • doctor::Resource is now #[non_exhaustive] and has gained a
    ProductionSafety variant. The variant is first, and Resource is the
    key type of the BTreeMap the doctor report is built from, so its derived
    Ord sets the report's display order β€” production safety now leads, and
    every other variant shifts by one. Code comparing or sorting Resource
    values sees the new order.

  • The three built-in number filters take Tera 2 signatures.
    views::tera_builtins::filters::number::{number_with_delimiter, number_to_human_size, number_to_percentage} are (value, Kwargs, &State)
    rather than (value, &HashMap). They are pub, so calling them directly β€”
    rather than through a template β€” needs updating. Templates are unaffected.

  • auth.jwt.location is parsed strictly. Its Deserialize is hand-written
    rather than #[serde(untagged)], so it accepts exactly the documented shapes
    β€” a map, or a list of maps β€” and reports a real error for anything else
    instead of a generic "did not match any variant". Config that happened to
    parse through untagged fallback will now be rejected with a message naming
    the problem. The serialized form is unchanged.

  • bgworker::pg::get_jobs returned Result<Vec<Job>, sqlx::Error> where the
    SQLite driver's returned loco_rs::Result. It now returns loco_rs::Result
    too. Callers using ? in a Loco context are unaffected; code matching on
    sqlx::Error needs updating.

  • loco_gen::AppInfo gains a working_dir field, so post-generation checks
    read the tree that was generated into rather than the process's current
    directory. Callers using new_generator() should pass ".".into().

  • loco_gen::Component::Scaffold and ::Controller gain a required auth: bool field, backing the new --no-auth / --auth flags. Code constructing
    these variants directly β€” rather than going through the CLI β€” must supply it.

  • loco_gen::DeploymentKind and the CLI's DeploymentKind both gain a
    Lambda variant. Neither is #[non_exhaustive], so an exhaustive match
    over either stops compiling until the arm is added.

  • post_process now runs before templates are loaded, in both the
    on-disk and embedded view engines. A build_with_post_process closure can
    no longer inspect loaded templates β€” it registers into an empty engine β€” and
    anything a template calls must be registered by that closure, or template
    loading fails. This is what lets a custom filter be visible to the templates
    that use it; previously registration happened too late.

Added

  • --no-auth on generate scaffold, --auth on generate controller.
    Scaffolded routes take an auth::JWT extractor on all five handlers, which
    is the right default but had no opt-out and nothing said so β€” the first
    curl against a fresh scaffold answered 401 with no explanation, and the
    tutorial's own examples were among the casualties. The scaffold now prints
    which flavor it generated, and --no-auth emits public routes. A generated
    controller has no model to protect and stays public by default; --auth is
    its opt-in mirror, and it adjusts the generated request test to assert the
    route rejects anonymous callers rather than expecting 200.

    cargo loco generate now runs a best-effort cargo fmt afterwards, the way
    loco new already did. A Tera template cannot know where rustfmt would break
    a line β€” with the auth argument gone, three handler signatures fit on one β€”
    and a generated app runs cargo fmt --check in its own CI, so non-canonical
    output would have failed a user's build on code they did not write.

  • cargo loco jobs retry moves failed jobs back to queued β€” with
    --id <ID> for one, or bare for all of them. No queue driver has automatic
    retry or backoff, so a failed job used to be terminal: requeue, the verb
    that sounds like the recourse, only rescues jobs a crashed worker stranded in
    processing and cannot touch a failed one. run_at is reset so a job that
    failed on a future-dated schedule runs now rather than when that time
    arrives. On the Redis provider a retried job is queued to default: the
    queue a job was submitted to is not recorded once it fails. The command says
    so when it retries anything, but operators running multiple named queues
    should know before they need it.

  • The loco CLI now declares rust-version = "1.94", matching the framework.
    cargo install loco on an older toolchain refuses up front with the required
    version instead of failing partway through a build.

  • QueueConfig::dangerously_flush() is now public, for tests and tooling that
    need to empty a queue outright.

  • AWS Lambda deployment generator β€” cargo loco generate deployment lambda
    writes a src/bin/lambda.rs entrypoint, adds lambda_http, and writes a
    [package.metadata.lambda] block so cargo lambda build and
    cargo lambda deploy need no flags. Loco's router is a tower::Service and
    so is the Lambda runtime, so the app runs unchanged; deployment is delegated
    to cargo-lambda rather than embedding an AWS SDK. HTTP only β€” workers and
    the scheduler do not fit Lambda's model.
    (#1699)

  • AppContext::into_builder β€” the escape hatch Hooks::after_context was
    missing. AppContext is #[non_exhaustive], so AppContext { storage, ..ctx }
    (the idiom the storage how-to showed) does not compile outside loco-rs, and
    rebuilding from AppContext::builder silently drops the mailer, queue
    provider, cache and shared store that boot had already attached.
    ctx.into_builder().storage(..).build() replaces one component and keeps the
    rest.

  • loco_rs::schema::rename_column, alongside add_column/remove_column.

  • PageResponse is now Serialize/Deserialize. The pagination how-to
    showed format::json(res) returning one straight from a handler, and printed
    the JSON body it produces; that could not compile, because the struct derived
    only Debug. The documented body is now pinned by a test.

Security

  • opendal 0.57 β†’ 0.58.1, which moves quick-xml from ^0.39.3 to
    ^0.41.0 in the S3, Azure and GCS service crates. quick-xml below 0.41.0
    carries RUSTSEC-2026-0194 (quadratic time checking a start tag for duplicate
    attribute names) and RUSTSEC-2026-0195 (unbounded namespace-declaration
    allocation); both are denial of service, and both are patched = [">= 0.41.0"].

    This reaches only apps that enable a cloud storage feature β€” storage_aws_s3,
    storage_azure, storage_gcp, all off by default β€” and the XML being parsed
    is the storage backend's own responses, so exploiting it means controlling
    what that endpoint returns. Narrow, but real if you point Loco at an
    S3-compatible endpoint you do not run.

    This fix ships in 1.1.0, not in 1.0.x. The 1.0.0 section below described
    the bump as if it had shipped there; it had not β€” 1.0.0 and 1.0.1 both went
    out pinning opendal = "0.57". That text has been corrected. If you enabled a
    cloud storage feature on 1.0.0 or 1.0.1, upgrading to 1.1.0 is the fix.

Fixed

  • On the SQLite queue driver, clearing the queue stopped it handing out jobs
    β€” permanently and silently.
    dequeue claimed an advisory lock by updating
    a row in a second table, sqlt_loco_queue_lock, while clear deleted every
    row in that table. With the row gone the lock could never be acquired, so
    dequeue returned "no jobs" forever, with the jobs sitting right there. Any
    app configured with queue.dangerously_flush: true hit this on every boot β€”
    converge runs setup and then clear β€” so its workers never ran a single
    job. The existing test asserted the row count was zero afterwards, locking
    the bug in.

    The lock table is gone entirely. It was a hand-rolled stand-in for BEGIN IMMEDIATE, which is how SQLite itself takes the write lock before the
    SELECT that picks a job β€” a plain BEGIN defers it until the first write
    and leaves the read unprotected, which is the race the table existed to
    close. initialize_database drops sqlt_loco_queue_lock if it is still
    there, so upgrading needs no action. The mutual-exclusion guarantee is now
    covered by a test that runs concurrent dequeues and fails without the fix.

  • examples/reference_spa did not build for anyone but its author. Its
    manifest pinned loco-rs to an absolute path on the maintainer's machine
    (path = "/Users/…/loco") β€” the shape LOCO_DEV_MODE_PATH generates β€” so
    every other checkout failed with failed to load manifest for dependency loco-rs. It is path = "../.." now, and CI runs both examples' test suites,
    which is what caught it.

  • snipdoc overwrote the translated READMEs. Each translation carries the
    same <snip> regions as README.md, so every injection run replaced the
    translated tagline, install comment and loco new transcript with the English
    source β€” and CI's snipdoc check then failed whenever a translator put their
    version back. The Spanish and Vietnamese READMEs had already lost theirs.
    snipdoc-config.yml now excludes the translated filenames from the walk, so
    only the canonical README.md is injected, and the two translations are
    restored to what their translators wrote.

  • On the Redis queue driver, a job vanished from every operator tool the
    moment it stopped being runnable.
    get_jobs enumerated jobs by walking the
    queue and processing keys, but complete_job and fail_job both remove the
    id from the processing set and add it to no queue β€” so jobs dump --status failed, jobs purge, clear_by_status and clear_jobs_older_than all
    silently reported nothing for completed and failed jobs. It now enumerates the
    job:* keys, which are the record of a job's existence, and consults the
    processing sets only to tell a job a worker is holding from one still queued.
    The existing test could not catch this: it asserted inside
    for job in &failed_jobs, which passes on an empty list β€” and the list was
    always empty.

  • Test snapshots no longer break west of UTC. get_cleanup_date's
    timestamp rule ended in \+\d{2}:\d{2}, matching only a positive UTC
    offset. It is the only rule that consumes the offset, so on a machine west of
    UTC the timestamp fell through to the offset-less rules, which stop at the
    seconds β€” redacting to DATE-03:00 instead of DATE and failing every
    snapshot carrying a timestamp. A freshly generated app therefore had a red
    test suite out of the box for everyone west of UTC, and had since 0.14.
    CI never caught it because GitHub runners are UTC.
    (#1802)

  • Every npm advisory in website/ and the reference SPA is cleared, and the
    loco new frontend template no longer pins vite/@vitejs/plugin-react to
    a floating "latest". Its react-router floor moves to ^8.3.0, below which
    a generated app resolves a version with an RSC-mode CSRF bypass.

  • Config templating is now YAML-safe (<%= ... %> instead of {{ ... }}).
    { is a YAML flow-mapping indicator, so port: {{ get_env(...) }} was never
    valid YAML at rest β€” it only parsed because Loco's template pass rewrote the
    file first. Any tool reading the file as YAML (prettier, yaml-language-server,
    format-on-save) restructured it into { { ... } } and broke startup.
    < is not a YAML indicator, so the new form is an ordinary string scalar:
    config files are valid YAML before rendering and survive formatting untouched.
    Three tag forms are supported β€” <%= expr %>, <% stmt %>, <%# text %>.
    Not a breaking change: legacy {{ }} still renders, with a deprecation
    warning. (#1727)

  • Environment variables in generated configs are no longer baked in at scaffold
    time.
    Because the generator and the runtime shared the same {{ }} delimiters,
    several lookups in a newly generated app were evaluated by loco new and frozen
    into the file β€” so PORT, BINDING, LOG_LEVEL, DB_LOGGING and MAILER_HOST
    silently had no effect at runtime. Only the handful of lookups that were manually
    {% raw %}-escaped survived. The two layers now use distinct delimiters, so every
    lookup reaches runtime as intended (and the {% raw %} escaping is gone from the
    templates).

  • Generated apps pinned a loco-rs that could not read their own config.
    LOCO_VERSION β€” the version requirement written into every generated
    Cargo.toml β€” still said 1.0, so a fresh app resolved the newest published
    1.0.x, which renders the new <%= ... %> config delimiters literally and then
    fails to parse them: the app compiled and died at boot. The floor now tracks
    the release, enforced by a test.

    The reason it went stale is its own bug: cargo xtask bump maintains that
    constant, but its search pattern was pinned to the literal "0.13" and a
    pattern that matched nothing was a printed note rather than an error. Every
    release since 0.14 reported success while leaving the floor untouched. A
    no-match is now a failure, and each of the four version sites the tool
    rewrites is covered by a test that it still matches.

  • config/production.yaml was generated as a 0-byte file β€” copied verbatim
    instead of rendered, since the CLI generator rewrite β€” and the generated
    .gitignore excluded it, so even a correct one would never have reached a
    server. Production is now a real template: backtraces off, json logs,
    0.0.0.0 binding rather than loopback (unreachable from outside a container),
    and a connection pool that isn't the development default of one. The ignore
    rule is gone; secrets live in the environment, so the file is infrastructure
    and belongs in the repository. local.yaml stays ignored.

  • doctor --production checked the wrong environment and ran fewer checks.
    The flag never selected an environment β€” it filtered checks while the config
    under test stayed whatever was ambient, and the default environment is
    development. On a server without LOCO_ENV set it reported a clean bill of
    health for the development database and never opened the production config.
    It is now a deprecated alias for --environment production, and production
    additionally checks settings that are harmless in development and not live: a
    loopback binding, dangerously_truncate/dangerously_recreate, a queue that
    flushes on startup, backtraces left on.

  • A generated migration that could not be registered reported success.
    Through rrgen 0.5, a before: injection whose anchor line was absent was not
    an error β€” it rewrote the file unchanged and still printed injected: …. A
    migration/src/lib.rs without the inject-above comment therefore accepted
    the mod declaration and silently dropped the Box::new(..) registration:
    the migration compiled, never ran, so the table was never created, db entities correctly wrote nothing, and the first insert 500'd at runtime.

    Fixed at the source in rrgen 0.6, which Loco now requires: an injection
    that cannot find its anchor fails, naming the file, the pattern and the
    content it could not place, and the failed generation writes nothing at all β€”
    so restoring the anchor and re-running does the whole job. This covers every
    injecting generator, not just migrations: controllers, scaffolds, tasks and
    the frontend route table all inject the same way.

    Loco additionally fails generation if any migration in migration/src/ is
    unregistered. That catches what an injection cannot see: a registration that
    went missing on an earlier run or by hand.

  • Generated apps are now booted, not just compiled, by the test suite. The
    wizard matrix starts each generated app and requires /_ping and /_health
    to answer 200, in development and again in production β€” the environment
    nothing exercised. The three fixes above all shipped in states that a full
    green suite could not see, because nothing in the repository ever ran the
    artifact a user receives.

  • loco-gen no longer depends on Tera. The dependency was unused; rrgen
    carries its own Tera 1, which coexists with Tera 2 without API contact.

  • loco new --assets serverside --embedded-assets produced an app that did
    not compile.
    The embedded view engine was missing the
    build_with_post_process constructor that the generated view-engine
    initializer calls, so the two engines were not interchangeable. Both now
    expose the same constructors, and the wizard test matrix builds this
    combination end to end instead of only asserting on wizard settings.

  • Generated server-side apps now test their own view rendering. A new
    tests/views/ case renders the shipped Tera template through the real view
    engine, including the i18n t() function, so view-engine regressions surface
    in an app's own test suite rather than at boot.

  • Scaffolding a second resource broke the SPA build. Every resource's pages
    are named List/New/Show/Edit, and the route injection imported them
    bare β€” so the second generate scaffold in an app injected a duplicate
    binding for all four names into frontend/src/routes.tsx. Imports are now
    aliased per resource (List as PostsList). Existing routes.tsx entries are
    your code and are untouched; new scaffolds emit the aliased form.

  • A custom foreign-key column name never reached the scaffold.
    user:references:admin_id names the FK column explicitly and the migration
    honours it, but the DTO and controller derived user_id regardless and
    referenced a column the entity does not have.

  • code:string!^ failed to parse. Only one of the two flag suffixes was
    stripped, leaving string! as the type name and reporting an unknown base
    type the user never wrote. Both flags now parse in any order and in either
    position (decimal_len!^:8:24 and decimal_len:8:24^! are the same column).
    ^ already implies non-null, so the combination is redundant β€” but it should
    not have been an error.

  • Pager could not deserialize its own output. A serialize-only rename
    emitted {"results": .., "pagination": ..} while the deserializer looked for
    info, so the derived Deserialize β€” public API β€” failed with
    missing field \info``. No wire format changed.

  • The legacy config-delimiter deprecation warning was unreachable. It was a
    tracing::warn! emitted during load_config, which runs before
    logger::init, so no subscriber existed to receive it. It now goes to stderr,
    where config-time diagnostics belong.

  • The generated config shows how to move the JWT out of the header.
    auth.jwt.location (cookie or query parameter) had no example in a generated
    app.

  • auth.jwt.location reports what is actually wrong with it. The setting
    was an #[serde(untagged)] enum, so a misspelled from: cookie, a Cookie
    with no name, and a bare scalar all produced the identical data did not match any variant of untagged enum JWTLocationConfig. The inner error now
    propagates β€” unknown variant \cookie`, expected one of `Bearer`,
    `Query`, `Cookie``. The accepted YAML and the serialized form are
    unchanged.

  • generate migration Rename<Old>To<New>On<Table> now generates a real
    migration
    instead of a todo!() stub announced as ready to run. Adds
    loco_rs::schema::rename_column. When a name genuinely can't be inferred the
    stub remains β€” silently succeeding would record it as applied β€” but the
    generator now says it is unimplemented, that db migrate will panic, and
    which names it does understand.

  • The starter's tests no longer snapshot whole models. Adding one column to
    users broke five generated tests at once, because they pinned an entire
    users::Model Debug dump. They snapshot the fields under test now, and
    assert the rest directly.

  • The generated development.yaml names a mail catcher and the PORT
    override.
    The dev mailer targets localhost:1025 with no stub, so mail
    failed unless something was listening and nothing said what; and every app
    defaults to port 5150, so a second one collides.

  • A missing database is documented as the one-way door it is. --db none
    turns off with-db, and no generator reverses it; there is now a how-to with
    the exact procedure, and the clientside React/ts-rs mode β€” previously
    undocumented in full β€” has a guide of its own.