Update
voltro update — what the command does and does not do. It bumps and installs; it does not make your app boot. The boot refusals this release ships, and the order you meet them.
voltro update upgrades an app to the latest framework release. It does three things in order:
- Bump every
@voltro/*dependency inpackage.jsonto the target version. - Install with your project's package manager — see Which package manager below.
- Run the codemods shipped with the target version. A codemod is either a transform (rewrites your source) or
manual(prints written steps, only when your app is affected). Every one of 0.34.0's 21 codemods ismanual— nothing is rewritten for you, and there is no diff to review afterwards.
voltro update does not make your app boot. It moves versions and prints
instructions; deciding what those instructions mean for your code is yours.
0.34.0 ships six boot refusals — three of them fire on voltro dev, before
you deploy anything — and update, db apply and typecheck all pass while an
app is dead in every one of them. Start with voltro doctor,
then read the boot refusals.
For the per-change narrative — what each of the 21 notes is about and why — read Upgrading to 0.34.0. This page is the command's own contract.
voltro update # bump to the latest published version, install, run codemods
voltro update --to 0.4.0 # pin an explicit target version
voltro update --dry-run # preview the bump + which codemods would run — writes nothing
voltro update --force # allow a dirty working tree (not recommended)
voltro update --only 0.14.0/03_pages-suffix # run just these codemod(s), comma-separated
voltro update --exact # pin exact versions (drop the ^ / ~ range prefix)
voltro update --help # every flag — always answered, even on a dirty tree
# Recover the codemods after a MANUAL version bump (no bump, no install):
voltro update --codemods-only --from 0.3.0 # re-run codemods 0.3.0 → installed
voltro update --codemods-only --from 0.3.0 --to 0.4.0 # explicit deltaStart here: voltro doctor
voltro doctor is the command that lists what will refuse to boot, and it
reports the exact set the boot refuses on, from the same function — not a
second implementation that can disagree with it.
voltro update
voltro doctor # every undecided procedure + every unverified webhook, by tag and file
voltro doctor --json # accessDecisions.undecided / webhookVerification.unverified — for CIRun it before you try to start anything. Its two most important sections are the two source-shaped refusals below:
access decisions · security.defaultDeny ON
✗ no access decision 18
✓ openAccess, declared on purpose 0
✗ notes.list (query)
api/notes/list.query.ts
…
`voltro dev` and `voltro serve` REFUSE to boot on these. Give each a decision:
`guards: [{ scope: '…' }]`, or `openAccess: '<why anyone may call it>'`.What doctor cannot see. It reads your source tree, so it covers the access
decisions and the webhook declarations. The other four refusals are properties
of your environment — a connection URL, NODE_ENV, a migration that has not
run against a particular database — and no source scan can predict them. Read
the list below for those.
The boot refusals, and where you meet them
Six of them ship in 0.34.0. voltro update succeeds, voltro db apply
succeeds and voltro typecheck succeeds in all six — the access decision is
a runtime boot gate, not a type error, and the other five are environment facts
no compiler is looking at. They are listed here in the order you actually meet
them: the first three on your own machine, the last three in a container.
On your laptop — voltro dev
1. A wire-exposed procedure that decides nothing. guards: used to default
to allowed, so a *.query.ts with no guard was callable by any authenticated
session. Every discovered procedure now declares guards: or openAccess:, or
neither voltro dev nor voltro serve starts.
[access] 18 wire-exposed procedures declare no access decision, and this app runs with `security.defaultDeny`:
notes.list (query)
api/notes/list.query.ts
…
Each of these is callable by ANY authenticated session. Give each one a
decision — the two are equally acceptable and they are not the same claim:
guards: [{ scope: 'invoices:read' }] the caller must hold a scope
openAccess: 'public pricing, no user data' anyone may call it, and whyThree things worth knowing before you start editing:
openAccesstakes a reason, not a boolean. It is what makes "we decided this is open" distinguishable from "nobody looked".- Do not rubber-stamp with a scope every caller already holds. That satisfies the gate, reads as protection, and enforces nothing.
- A procedure only other server code calls wants neither. Mark it
internal: trueand it leaves the wire entirely (and then it must not carryopenAccess— the definers refuse that combination). - Your plugins' procedures are not your problem. The gate reads your app's own discovered files only.
The one-field escape hatch, if you need to ship before you have decided everything:
// app.config.ts
export default { security: { defaultDeny: false } }That restores the old default-allow for the WHOLE app, in one place a reviewer
can see. There is deliberately no env var for it — an env var is how a security
default gets turned off in one CI job and stays off. voltro doctor keeps
listing the undecided procedures while it is off, marked advisory.
2. An incoming webhook that does not say how it authenticates its caller. An incoming webhook is a public, unauthenticated POST that runs your application code. The transport refuses to mount one that declared nothing:
incoming webhook '/webhooks/stripe' is mounted without declaring how it authenticates its caller.
An incoming webhook is a public POST that runs your application code, so the framework refuses
to mount one that nothing verifies. Declare it on the descriptor:
· provider: stripeWebhookProvider() — or any provider preset (HMAC + replay window)
· signature: { _tag: 'hmac', algorithm: 'hmacSha256', header: 'X-Signature', ... }
· verification: 'provider' — the handler verifies with the provider's own SDK
· verification: 'none' — deliberately public (gateway / IP allow-list owns it)
A signature-verified webhook also needs its shared secret in VOLTRO_WEBHOOK_SECRET_<ID>;
the framework mints no secret for you.verification: 'none' is a legitimate answer when a gateway or IP allow-list
owns the trust boundary. It has to be said, which is the whole change.
3. A mysql / mariadb / mssql URL asking for TLS the dialect cannot honour.
Both dialects used to DROP a TLS request rather than reject it, so
DB_URL=mysql://…?ssl=true connected in plaintext with no warning. Only the two
modes the cross-dialect ssl boolean can express are accepted; everything else
throws where the connection is built — which is voltro dev, voltro serve,
voltro db apply and voltro migrate alike.
DB_URL '?sslmode=verify-full' is not supported by the mysql/mariadb dialect —
use 'require' (TLS without certificate verification) or 'disable' (plaintext).| URL says | Result |
|---|---|
?sslmode=require / ?ssl=true / ?ssl=1 |
TLS, certificate NOT verified |
?encrypt=1 |
same (mssql only — tedious' spelling) |
?sslmode=disable / ?ssl=false / ?ssl=0 |
plaintext, explicitly |
prefer, allow, verify-ca, verify-full, ?ssl=yes, a CA-profile name |
throws at boot |
If your URL said ?ssl=true you were being lied to — that connection has been
plaintext, and it is real now. Confirm your server accepts TLS before rolling
out. Check it from the database rather than from the config:
-- mysql / mariadb: empty = plaintext, a cipher name = encrypted
SHOW STATUS LIKE 'Ssl_cipher';
-- mssql: FALSE / TRUE
SELECT encrypt_option FROM sys.dm_exec_connections WHERE session_id = @@SPID;postgres, sqlite and turso are unaffected.
In the container — voltro serve / voltro start
These three fire only on a deploy environment (NODE_ENV=production or
staging), which is exactly why they are the expensive ones: nothing on your
machine reproduces them.
4. Pending migrations/*.migration.ts that have never run against this
database. It always had to run before serve; what changed is that skipping it
is loud. Serve's other schema guard is a declarative fingerprint diff, and a
file migration exists for the changes a state diff cannot infer — a data move, a
backfill, a cross-table rewrite. Those move no fingerprint, so the guard passed
and production ran un-migrated.
serve: refusing to boot — 3 pending file-based migration(s) have never run against this
database. They perform the changes a schema diff cannot infer (data moves, backfills, table
splits), so the declarative fingerprint check below cannot see them and would have let this
process serve un-migrated data.
Run them from your pre-deploy job — `voltro db migrate .` (schema + files) or `voltro db files .`
(files alone) — or set VOLTRO_AUTO_MIGRATE=0 to bypass every boot schema check. `voltro serve`
never applies them itself: a rolling deploy would start N replicas and each would try.Serve will not apply them for you, deliberately: a rolling deploy starts N replicas, each would try, and the migration lock turns that into N-1 processes blocked on boot. A refusal is recoverable in one command; a fleet wedged behind a lock is not.
5. plugin-search on the in-memory backend. The heap-resident index is
per-process AND non-durable — it starts empty after every deploy and nothing
re-seeds it — so a single replica does not make it correct.
plugin-search refuses to boot in production on the in-memory backend.
The memory index lives in THIS process's heap. Two consequences, both silent:
• every replica holds a different index, so a result depends on which replica served you;
• the index starts EMPTY after every restart/deploy, and nothing re-seeds it automatically.
Configure a durable engine in app.config.ts:
searchPlugin({ backend: { engine: 'typesense', url: …, apiKey: … }, indexes })
…If your deployment genuinely is one process that calls backfillIndex at
startup, declare it: searchPlugin({ singleProcessMemoryIndex: true, indexes })
— a claim the plugin holds you to, not a mute switch. Full reasoning:
the memory backend refuses to boot in production.
6. SSR_CACHE=postgres with no database in the web process's environment.
voltro start used to select the postgres ISR cache only when PG_HOST was
set, while every template and every deployment doc configures DB_URL — so an
app that asked for the shared cache the documented way silently got the
per-process memory one, reported at info as if it were the default. Both sides
go through the connection resolver now, and the mismatch is fatal on a deploy
environment:
SSR_CACHE=postgres, but nothing in the environment names a database (looked for DB_URL,
DB_PRIMARY_URL, DB_DIRECT_URL, DB_MIGRATE_URL, DB_HOST, PG_HOST). Refusing to fall back to
the per-process memory cache: it is not shared between instances and does not survive a
restart, so the pages this process serves would differ from its replicas' with nothing to
indicate it.Either give the web process a DB_URL, or drop SSR_CACHE=postgres and take
the memory cache deliberately. Off a deploy environment it warns and falls back
instead. Two knock-on effects with nothing to edit: pages declaring
cacheInvalidatesOn that had no live invalidation now have it (a real change
in origin load), and a web process with no database that declares
cacheInvalidatesOn gets a boot warning naming those routes.
Taking only part of the jump — --only
Ids are what --dry-run prints:
voltro update --dry-run
voltro update --codemods-only --from 0.13.0 --only 0.14.0/03_pages-suffix,0.14.0/02_reactive-by-defaultUseful when part of a jump is load-bearing (without it the app does not build or its routes 404) and part is elective: take the necessary ones, get back to a committable tree, then run the rest. An id that matches nothing in the jump is an error listing the ids that do — "it did nothing" and "you typed it wrong" would otherwise look identical.
There is no --required flag, deliberately. "Required" would have to mean this
app does not run without it, and that is a property of your app rather than of
the codemod: the pages rename is unavoidable for a project with pages and
irrelevant to an api-only one. You know which ones you need; we would be guessing.
In a workspace, the whole workspace moves
Run voltro update anywhere inside a workspace — a pnpm-workspace.yaml, or a
workspaces field in an ancestor package.json — and every member
package.json that declares @voltro/* is bumped to the same version, with
the install running once at the workspace root.
This is not a convenience. Your api and your web app share generated types (the
rpcGroup) and a session cookie shape; if the api moves to 0.6.0 while
apps/web and packages/ui-* stay on 0.5.0, the mismatch shows up as a runtime
decode error in the browser, not as a build failure. Half-upgraded is the worst
state to be in, so voltro update never leaves you there.
The plan output — and --dry-run — lists every file it will touch:
voltro update: 0.5.0 → 0.6.0
workspace: /repo (4 package.json with @voltro/* deps)
package.json
@voltro/cli: ^0.5.0 → ^0.6.0
apps/api/package.json
@voltro/cli: ^0.5.0 → ^0.6.0
@voltro/database: ^0.5.0 → ^0.6.0
apps/web/package.json
@voltro/client: ^0.5.0 → ^0.6.0
packages/ui-admin/package.json
@voltro/web: ~0.5.0 → ~0.6.0
package manager: pnpm
install runs in: /repoA standalone (non-workspace) project is unaffected: its own package.json, its
own install, in place.
Already bumped by hand? Recover the codemods
If you bump @voltro/* versions in package.json yourself and install first, a
plain voltro update sees the installed version already equals the target and
reports "already on X — nothing to do" — skipping the codemods AND the
printed manual steps for the delta you actually crossed. To re-apply them without
touching package.json again:
voltro update --codemods-only --from <version-you-came-from>--codemods-only (alias --run-codemods) runs the codemods + manual notes for
[from, to] against the already-installed tree — no version bump, no install.
--to defaults to the installed version; pass it to pin an explicit delta.
--from also works on a normal voltro update to override the auto-detected
source version.
The clean-tree guard
voltro update refuses to run on a dirty git working tree — commit or stash
first. Use --dry-run to preview without touching anything, or --force to
override the guard (you accept a mixed diff).
The guard is about the writes update makes on your behalf: the version bump
across every workspace package.json, the lockfile the install rewrites, and —
in a release that ships one — a transform codemod rewriting your source.
When the jump's codemods are all manual, as 0.34.0's 21 are, update writes
nothing under src/ at all, and the work the printed notes describe is a
separate commit you author yourself.
--help / -h is answered before the guard, so voltro update --help prints the flag list even on a dirty tree. The same holds for voltro doctor --help.
If the install fails
The bump is written before the install runs, so a failed install leaves your package.json on the target version — and no codemods applied. voltro update says so explicitly, because the codemods for a jump ship inside the target version: a failed install never put them on disk, so there is nothing that could have run them. Fix the install, run it, then apply the codemods you are missing with the command the failure message prints for you:
voltro update --codemods-only --from 0.5.0 --to 0.6.0What gets bumped
Every @voltro/* entry in dependencies and devDependencies — in every workspace member, see above — with the range style preserved (^0.3.0 stays caret, ~0.3.0 stays tilde) unless you pass --exact. Non-registry specs (workspace:*, catalog:, link:, …) are left untouched — they're already resolved by your monorepo or catalog.
And the peer dependencies the framework requires
@effect/* are peer dependencies, so your app declares them directly. When a
release moves its peer range, bumping only @voltro/* leaves you installed
against the old ones:
Aligning peer dependencies the framework requires:
@effect/rpc ^0.75.1 → ^0.76.0 (apps/api/package.json)
@effect/platform ^0.96.2 → ^0.97.0 (apps/api/package.json)update reads those requirements off the freshly installed @voltro/* packages
and re-installs if anything moved. Without it your package manager only warns,
and the app compiles and boots on a graph the framework was never tested against
— which is the failure mode with no symptom until there is one.
It is deliberately conservative:
- Only peers you already declare. One resolved transitively is not
update's to add. - Only when your range is genuinely lower. Pinned ahead, or pinned exactly at
the floor (
0.76.0vs^0.76.0), is left alone — that is a choice. - Only ranges it can judge (
^,~,>=, exact). A union (^1 || ^2), a bounded range,workspace:/catalog:— untouched.
If two framework packages disagree about one peer, it says so and changes nothing: that is our bug, not yours to absorb silently.
When the install cannot run on this host
Some projects install in a container with their own store, from an offline
mirror, or in a locked-down CI image. voltro update runs your package manager
on the machine you invoke it from, so on those hosts the install step fails —
and it fails after the version bump is written, which leaves the tree
half-upgraded.
--no-install splits the command where those projects need it split:
voltro update --no-install --to 0.14.0 # writes the bump, stops, says what is left
# ...install however this project installs...
voltro update --codemods-only --from 0.13.0 --to 0.14.0Step two is not optional and the command says so: the codemods for a jump ship inside the target version, so nothing can run them until the install has put that version on disk.
Which package manager
voltro update never assumes npm. It resolves your project's package manager in this order, starting in the app directory and walking up to the repo root:
- The
packageManagerfield in apackage.json(the corepack standard) — authoritative, wins over any lockfile. - A lockfile at that level —
pnpm-lock.yaml,yarn.lock,bun.lock/bun.lockb,package-lock.json. - npm, only when nothing declares one.
Walking up matters in a workspace: a scaffolded Voltro project keeps its lockfile at the monorepo root, so running voltro update from apps/api still finds pnpm rather than falling back to npm and running npm install against a pnpm workspace.
The same resolved manager is used for the registry lookup of the latest version (pnpm view, yarn npm info, bun pm view), so a private or scoped registry configured in your .npmrc / .yarnrc.yml is honored. npm view is only a last-resort fallback.
Codemods
Each breaking public-API change in a release ships a codemod, and there are exactly two kinds:
- A transform codemod rewrites your source automatically — renamed imports, moved modules, changed component props, restructured call signatures. The rewrite is scoped to files that actually import the affected symbol. Where the affected sites can be found but the fix needs your judgment, it inserts
// TODO(voltro-migration): …markers so you can locate every spot. - A manual codemod prints written steps during the update,
appliesTo-gated so you see it only when your app is actually affected. It writes nothing.
0.34.0's are all manual — 21 of them, zero transforms. That is not an omission. The largest change in the release asks a question only you can answer ("who may call this procedure?"), and a transform could have answered it mechanically for every procedure in your app — declaring your entire surface open on purpose, in one commit nobody reads, with a reason the tool invented. The framework does not sign that.
So on this jump the output is a wall of text and no diff:
Manual steps required (could not be automated):
▸ 0.34.0/03_procedure-access-decision — Every wire-exposed procedure declares an access decision (`guards:` or `openAccess:`)
YOUR APP WILL NOT BOOT UNTIL EVERY WIRE-EXPOSED PROCEDURE DECIDES WHO MAY
CALL IT. …Read the notes. They are the only artefact the upgrade produces, and each one prints only because your tree matched it.
Codemods that span multiple versions run in order (e.g. upgrading 0.2.0 → 0.4.0 runs the 0.3.0 and 0.4.0 codemods in sequence).
When nothing changed, read WHICH nothing
Two states end an update with no diff, and they mean opposite things: the jump ships no codemods, or it ships some and every one of them decided your project is not affected. The summary names which:
codemods: 2 ship for this jump; none matched your project.
· 0.55.0/01_target-relations-declare-columns — its own check found nothing to change
· 0.55.0/02_widget-kind-gained-rich-text — its own check found nothing to changenone ship for this jump is the first. The second lists the ids, because a
codemod's own check is a predicate that can be wrong — and a gate that reads
the wrong files answers "does not apply" for a project that is fully affected.
If you recognise a subject in that list as something your app does use, that
is a bug in the check rather than a fact about your code; the CHANGELOG entry
for the version says what each one looks for.
The database is separate
voltro update does not touch your database. Framework-owned _voltro_* tables (workflow runs, schedules, …) are reconciled by the declarative differ, not by codemods: when a release changes one of those tables, your next voltro db apply (or voltro dev boot, which auto-applies) picks up the change.
Use voltro db apply (the declarative diff), not voltro db migrate (the imperative file-runner) — only the former reconciles framework tables. If your app also ships migrations/*.migration.ts, voltro db migrate . runs both halves and is what refusal 4 above asks your pre-deploy job for.
Restart every process — a running one keeps the OLD modules
voltro update changes what is on disk. A process that was already running when
you ran it keeps the module graph it loaded at boot, so it goes on executing the
previous version indefinitely — and against a .framework directory that has
since been rewritten.
That mix is worse than either version alone. A deployment lost half an hour to a pod whose api had started before the upgrade: it served requests, reported healthy, and returned no SSR at all, because the running process held the old modules while the build output on disk was new.
Restart every process after an update, including ones you did not deploy:
kubectl rollout restart deploy/api deploy/web # or: docker compose up -d --force-recreatevoltro dev reloads itself, so a development machine is not affected. Anything
long-running is — a voltro serve / voltro start container, a worker, a
process a supervisor kept alive across the upgrade.
After the update — the checklist
voltro update
voltro doctor # ← the one that can fail. Every undecided procedure + unverified webhook.
voltro db apply # reconcile any changed framework tables
voltro typecheck # your code against the new API surface
voltro dev # the first boot that actually exercises the gatesupdate, db apply and typecheck all pass on an app that will not start.
That is the shape to internalise: the access decision is a runtime boot gate, not
a type error; the webhook declaration is a descriptor property, not a signature;
and the environment-shaped refusals are facts about a container you have not
started yet. The only two steps in that list that can tell you the truth are
voltro doctor and an actual boot.
For a deploy, add the container-side ones to your pre-deploy job before the image rolls:
voltro db migrate . # schema diff AND file migrations — refusal 4
# and check by hand: the mysql/mssql DB_URL's ?sslmode (3), a durable search
# backend (5), and DB_URL on the WEB process if it sets SSR_CACHE=postgres (6)Where to read next
- Upgrading to 0.34.0 — the per-change narrative behind the 21 notes
- Migrate — schema changes end-to-end
- Build & start — production paths