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:

  1. Bump every @voltro/* dependency in package.json to the target version.
  2. Install with your project's package manager — see Which package manager below.
  3. 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 is manual — 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 delta

Start 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 CI

Run 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 why

Three things worth knowing before you start editing:

  • openAccess takes 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: true and it leaves the wire entirely (and then it must not carry openAccess — 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-default

Useful 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: /repo

A 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.0

What 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.0 vs ^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.0

Step 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:

  1. The packageManager field in a package.json (the corepack standard) — authoritative, wins over any lockfile.
  2. A lockfile at that level — pnpm-lock.yaml, yarn.lock, bun.lock / bun.lockb, package-lock.json.
  3. 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 change

none 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-recreate

voltro 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 gates

update, 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)