Packages

Database-driven top-level URLs in Phoenix without your declared routes getting shadowed — declared routes always win, order stops mattering.

Current section

Files

Jump to
dynamic_routes CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-08-21
Initial release.
### Added
- `use DynamicRoutes, resolver: MyApp.Pages` — installs dynamic routing into a
Phoenix router by overriding the `call/2` that `Phoenix.Router` leaves
overridable for exactly this.
- `dynamic_routes/2` — declares the internal route dynamic paths dispatch to.
Its position in the router does not matter.
- `DynamicRoutes.Resolver` — the one-callback behaviour a host application
implements to claim paths.
- `DynamicRoutes.resolution/1` and `dynamic?/1` — what the resolver returned,
and whether this request was routed dynamically.
- `DynamicRoutes.Cache` — a bounded, expiring ETS cache with
`invalidate/0` and `invalidate/1`.
### Fixed — first review pass
- **A percent-encoded path bypassed declared routes, and their pipelines.**
Plug never decodes path segments, Phoenix's own `call/2` decodes immediately
before matching, and `route_info/4` does not decode at all — so asking it
about a raw path asked a different question than the router answered a
moment later. `/admin` behind an auth pipeline returned 403 while `/%61dmin`
missed the lookup, reached the resolver, and was served as a database page
with the pipeline never run. The same bug shadowed any non-ASCII declared
route with no attacker involved, since a browser sends `/café` as
`/caf%C3%A9`. The path is now decoded once, and used for the lookup, the
resolver and the cache key alike.
- **The internal route was a live public URL.** `/__dynamic__/anything`
matched as a declared route and reached the controller having never been
through the resolver, with `resolution/1` returning `nil` — which the
documented controller body renders. A request that did not come through the
rewrite is now a 404.
- **The cache discarded the answer it had just paid for.** Clearing everything
on overflow fails at exactly the traffic it exists to survive: a scanner
walking random paths keeps the table permanently full, so real pages are
re-resolved on every request — measured at 91× database amplification.
Replaced with two generations, where an answer found in the previous one is
promoted, so anything actually in use survives rotation.
- **Cache reads raised while the owner process was restarting**, turning a
request that should have been merely slower into a 500. Every ETS call is
guarded now, not just the writes.
- **A second `dynamic_routes/2` declaration was silently dead** — both
compiled and every rewrite went to the first. It now refuses to start.
- **The internal path was memoised across recompiles**, so changing `:path` in
development left every dynamic request 404ing against the path that had just
been deleted, until the VM restarted. Not memoised when code reloading is on.
- **`defoverridable call: 2` was missing after the definition**, so another
library overriding `call/2` on top got a clause that could never match. The
comment explaining its absence was wrong about why.
- Paths longer than `:cache_max_key_bytes` are resolved but not cached.
### Fixed — verification pass
- **The code-reloading guard was dead code.** It read a config key nothing
sets — not the library, not Phoenix, not the docs — so the staleness it was
written to prevent was live in every installation. The memo is gone
entirely: it saved a filter over a compiled-in list, which is not worth a
correctness hazard.
- **The duplicate-declaration check raised at request time.** A router with
two declarations compiled, booted, served ordinary traffic, and then
produced a bare 500 on every dynamic page — the only pages the library
exists for. It is a compile-time fact, and is now checked in
`@after_compile`.
- **A reader arriving mid-rotation resolved and threw the answer away**,
because the `:badarg` catch sits above the insert. It retries once.
- **Promotion left the old copy in place**, so a hot key occupied two slots
against a bound expressed in entries.
- **The supervisor used the default three-restarts-in-five-seconds.** The
cache is a pure optimisation, so a burst of crashes taking the whole
application down with it is the wrong trade.
### Documentation corrected
The resolver's docs claimed its argument was already percent-decoded — it was
not, until this release. The README's central claim was false as written for
encoded paths. Nothing mentioned that a LiveView cannot be a target, that
precedence is per method as well as path, that dynamic pages answer every
method, or that a `forward/2` prefix cannot host them. All now stated.
### Design notes
- **Declared routes always win.** The router is asked first, through
`Phoenix.Router.route_info/4`, and only its misses reach the resolver. That
is what makes declaration order stop mattering, and it means a database row
can never shadow a route somebody deployed.
- **The path is restored before the controller runs.** Matching a
database-backed URL means temporarily replacing `conn.path_info`; leaving
that in place would make every canonical URL the controller builds name an
internal path no client ever requested. `DynamicRoutes.Dispatcher` sits
between the route and the controller and undoes it after the pipeline has
run.
- **Caching is in v1 rather than deferred.** The resolver is consulted on
requests that matched nothing, which on a public site means every scanner
probing for paths that do not exist. Misses are cached as well as hits, and
the cache is bounded, because its keys are chosen by whoever is making the
request.
- **The log names the real controller.** Phoenix logs the dispatch before the
dispatcher can correct anything, so the route carries `:mfa` metadata and a
dynamic request reads as `Processing with MyAppWeb.PageController.show/2`
rather than naming an internal module.