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.
What's new
- Tera 2 for views and mailers. For most apps this is one line: bump
fluent-templatesfrom0.13to0.15. Your templates don't need
rewriting unless they use{% macro %},{% import %}orv.0β Loco's
generated ones use none of those. - Deploy to Lambda β
cargo loco generate deployment lambda. Loco's
router is atower::Serviceand so is the Lambda runtime, so your app runs
unchanged. --no-authon scaffold,--authongenerate 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,listandstat; 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
listendpoints changed their JSON contract, and now use the
framework's own pagination instead of a second, parallel one. The scaffold
had its ownListParams, its own paginator arithmetic, and an envelope with
nototal_pagesβ whilequery::PaginationQuery+query::paginatealready
existed and carried it. The generated handler is now four lines over
query::paginate, andPage<T>(src/dtos/common.rs) is built by
Page::from_query.On the wire:
per_pageβpage_size,totalβtotal_items, and
total_pagesis added β the metadata field names arePagerMeta's, so an
app has one pagination vocabulary whichever envelope a handler returns. The
query parameter is likewisepage_size. Existing apps keep the code they
already generated; regenerating, or updating by hand, is a rename plus one
new field. The typed frontend'sbindings/Page.tsregenerates fromts-rs. -
QueueProvidergains a requiredretry_failedmethod. Custom queue
provider implementations must implement it; all three built-in drivers do.
(Queue::retry_failedis the inherent forwarding method on the handle and
requires nothing of anyone.) -
Storage
StoreDriver/StorageStrategygain requiredlistandstat
methods (andStorageStrategyalso requiresexists). Custom driver and
strategy implementations must implement them. Built-in drivers/strategies
already do. TheStoragefacade exposes matchingexists/list/stat
(and*_with_policy) APIs alongside the existing upload/download/delete
surface. UnderReplicatedStrategyin mirror mode,existsandlisttreat
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::Teraappears 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
Valuerather than(&Value, &HashMap)overserde_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 totera::load_from_glob, which yields the same template names Tera 1
produced, so existingrender("home/hello.html")calls are unaffected. -
Newly generated apps require their production secrets to be set. The
generatedconfig/production.yamltakes 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_URLfor a
Redis or database-backed queue, andMAILER_HOST/MAILER_USER/
MAILER_PASSWORDfor a mailer. Existing apps are unaffected β their config
files are their own. See Fixed below. -
doctor::Resourceis now#[non_exhaustive]and has gained a
ProductionSafetyvariant. The variant is first, andResourceis the
key type of theBTreeMapthe doctor report is built from, so its derived
Ordsets the report's display order β production safety now leads, and
every other variant shifts by one. Code comparing or sortingResource
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 arepub, so calling them directly β
rather than through a template β needs updating. Templates are unaffected. -
auth.jwt.locationis parsed strictly. ItsDeserializeis 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_jobsreturnedResult<Vec<Job>, sqlx::Error>where the
SQLite driver's returnedloco_rs::Result. It now returnsloco_rs::Result
too. Callers using?in a Loco context are unaffected; code matching on
sqlx::Errorneeds updating. -
loco_gen::AppInfogains aworking_dirfield, so post-generation checks
read the tree that was generated into rather than the process's current
directory. Callers usingnew_generator()should pass".".into(). -
loco_gen::Component::Scaffoldand::Controllergain a requiredauth: boolfield, backing the new--no-auth/--authflags. Code constructing
these variants directly β rather than going through the CLI β must supply it. -
loco_gen::DeploymentKindand the CLI'sDeploymentKindboth gain a
Lambdavariant. Neither is#[non_exhaustive], so an exhaustivematch
over either stops compiling until the arm is added. -
post_processnow runs before templates are loaded, in both the
on-disk and embedded view engines. Abuild_with_post_processclosure 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-authongenerate scaffold,--authongenerate controller.
Scaffolded routes take anauth::JWTextractor on all five handlers, which
is the right default but had no opt-out and nothing said so β the first
curlagainst 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-authemits public routes. A generated
controller has no model to protect and stays public by default;--authis
its opt-in mirror, and it adjusts the generated request test to assert the
route rejects anonymous callers rather than expecting 200.cargo loco generatenow runs a best-effortcargo fmtafterwards, the way
loco newalready 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 runscargo fmt --checkin its own CI, so non-canonical
output would have failed a user's build on code they did not write. -
cargo loco jobs retrymoves failed jobs back toqueuedβ 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
processingand cannot touch a failed one.run_atis 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 todefault: 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
locoCLI now declaresrust-version = "1.94", matching the framework.
cargo install locoon 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 asrc/bin/lambda.rsentrypoint, addslambda_http, and writes a
[package.metadata.lambda]block socargo lambda buildand
cargo lambda deployneed no flags. Loco's router is atower::Serviceand
so is the Lambda runtime, so the app runs unchanged; deployment is delegated
tocargo-lambdarather than embedding an AWS SDK. HTTP only β workers and
the scheduler do not fit Lambda's model.
(#1699) -
AppContext::into_builderβ the escape hatchHooks::after_contextwas
missing.AppContextis#[non_exhaustive], soAppContext { storage, ..ctx }
(the idiom the storage how-to showed) does not compile outsideloco-rs, and
rebuilding fromAppContext::buildersilently 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, alongsideadd_column/remove_column. -
PageResponseis nowSerialize/Deserialize. The pagination how-to
showedformat::json(res)returning one straight from a handler, and printed
the JSON body it produces; that could not compile, because the struct derived
onlyDebug. The documented body is now pinned by a test.
Security
-
opendal0.57 β 0.58.1, which movesquick-xmlfrom^0.39.3to
^0.41.0in the S3, Azure and GCS service crates.quick-xmlbelow 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 arepatched = [">= 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 pinningopendal = "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.dequeueclaimed an advisory lock by updating
a row in a second table,sqlt_loco_queue_lock, whilecleardeleted every
row in that table. With the row gone the lock could never be acquired, so
dequeuereturned "no jobs" forever, with the jobs sitting right there. Any
app configured withqueue.dangerously_flush: truehit this on every boot β
convergerunssetupand thenclearβ 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
SELECTthat picks a job β a plainBEGINdefers it until the first write
and leaves the read unprotected, which is the race the table existed to
close.initialize_databasedropssqlt_loco_queue_lockif 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_spadid not build for anyone but its author. Its
manifest pinnedloco-rsto an absolute path on the maintainer's machine
(path = "/Users/β¦/loco") β the shapeLOCO_DEV_MODE_PATHgenerates β so
every other checkout failed withfailed to load manifest for dependency loco-rs. It ispath = "../.."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 asREADME.md, so every injection run replaced the
translated tagline, install comment andloco newtranscript with the English
source β and CI'ssnipdoc checkthen failed whenever a translator put their
version back. The Spanish and Vietnamese READMEs had already lost theirs.
snipdoc-config.ymlnow excludes the translated filenames from the walk, so
only the canonicalREADME.mdis 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_jobsenumerated jobs by walking the
queue and processing keys, butcomplete_jobandfail_jobboth remove the
id from the processing set and add it to no queue β sojobs dump --status failed,jobs purge,clear_by_statusandclear_jobs_older_thanall
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 toDATE-03:00instead ofDATEand 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 newfrontend template no longer pinsvite/@vitejs/plugin-reactto
a floating"latest". Itsreact-routerfloor 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, soport: {{ 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 byloco newand frozen
into the file β soPORT,BINDING,LOG_LEVEL,DB_LOGGINGandMAILER_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-rsthat could not read their own config.
LOCO_VERSIONβ the version requirement written into every generated
Cargo.tomlβ still said1.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 bumpmaintains 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.yamlwas generated as a 0-byte file β copied verbatim
instead of rendered, since the CLI generator rewrite β and the generated
.gitignoreexcluded it, so even a correct one would never have reached a
server. Production is now a real template: backtraces off,jsonlogs,
0.0.0.0binding 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.yamlstays ignored. -
doctor --productionchecked 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 withoutLOCO_ENVset 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, abefore:injection whose anchor line was absent was not
an error β it rewrote the file unchanged and still printedinjected: β¦. A
migration/src/lib.rswithout theinject-abovecomment therefore accepted
themoddeclaration and silently dropped theBox::new(..)registration:
the migration compiled, never ran, so the table was never created,db entitiescorrectly 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/_pingand/_health
to answer 200, indevelopmentand again inproductionβ 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-genno 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-assetsproduced an app that did
not compile. The embedded view engine was missing the
build_with_post_processconstructor 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 i18nt()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 namedList/New/Show/Edit, and the route injection imported them
bare β so the secondgenerate scaffoldin an app injected a duplicate
binding for all four names intofrontend/src/routes.tsx. Imports are now
aliased per resource (List as PostsList). Existingroutes.tsxentries 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_idnames the FK column explicitly and the migration
honours it, but the DTO and controller deriveduser_idregardless and
referenced a column the entity does not have. -
code:string!^failed to parse. Only one of the two flag suffixes was
stripped, leavingstring!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:24anddecimal_len:8:24^!are the same column).
^already implies non-null, so the combination is redundant β but it should
not have been an error. -
Pagercould not deserialize its own output. A serialize-only rename
emitted{"results": .., "pagination": ..}while the deserializer looked for
info, so the derivedDeserializeβ 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 duringload_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.locationreports what is actually wrong with it. The setting
was an#[serde(untagged)]enum, so a misspelledfrom: cookie, aCookie
with noname, and a bare scalar all produced the identicaldata 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 atodo!()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, thatdb migratewill panic, and
which names it does understand. -
The starter's tests no longer snapshot whole models. Adding one column to
usersbroke five generated tests at once, because they pinned an entire
users::ModelDebugdump. They snapshot the fields under test now, and
assert the rest directly. -
The generated
development.yamlnames a mail catcher and thePORT
override. The dev mailer targetslocalhost:1025with nostub, 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 offwith-db, and no generator reverses it; there is now a how-to with
the exact procedure, and the clientside React/ts-rsmode β previously
undocumented in full β has a guide of its own.