Build & start
voltro build and voltro start — production builds, SSG pre-render, the SSR bundle, ISR cache.
voltro build produces a production artefact; voltro start serves it. Two commands, clean separation.
voltro build <appDir>
voltro build apps/acme/web
voltro build . # current dirWhat it does for a web app:
- vite build against
.framework/— producesdist/with chunked client bundle. - Pre-renders static pages — every page with
renderMode: 'static'is rendered to HTML once + lands atdist/<path>/index.html. - Pre-builds the SSR bundle — every page module compiled to
dist/server/ssrEntry.jssovoltro startdoesn't need a Vite middleware loader at runtime. - Copies
public/intodist/.
The temporary Vite module loader used while pre-rendering runs with HMR and its
WebSocket server disabled. A production build opens no listener — port 24678
belongs only to an interactive dev server.
Radix UI primitives use the same React instance as the renderer during development, static prerendering and source-based middleware rendering. UI packages from a separate workspace installation do not require an app-specific React alias.
For api apps, voltro build precompiles the whole handler closure — every procedure, workflow, subscriber, reaction, aggregate, agent, webhook, cron, startup, app.config, and their shared database/lib deps — into a single esbuild bundle at .framework/dist-api/apiEntry.js (the framework + npm deps stay external). voltro serve loads that bundle automatically at boot and resolves every handler from it, so production never transpiles TypeScript at runtime. Without a build, voltro serve still loads each source module on demand (via the tsx loader) exactly as voltro dev does — the build is an optimisation, not a requirement.
What an api build costs in memory scales with the app's module count. Each of the two api artefacts (the precompiled bundle and the serve bundle) is preceded by a closure-discovery pass that links one generated entry importing every discovery file, so esbuild walks the module graph once per artefact rather than once per handler file. The passes run one after another in a single esbuild service process, so the build's peak is its largest pass, not the sum. Measured on a synthetic api app: at about 1,000 app modules the esbuild service peaks around 0.9 GiB, at 3,000 around 1.0 GiB, at 6,700 (about 4,500 handler files) around 1.9 GiB, with the Node process at 0.4–0.5 GiB beside it; the floor is the serve bundle, which inlines the framework. Budget roughly 2.5 GiB for a build of the largest of those sizes on a shared host. Inside a container, /sys/fs/cgroup/memory.peak reads higher than that, because the kernel charges the container for the page cache of every file the build reads and writes — measured: a process that writes 1 GiB of files peaks at 49 MiB and moves memory.peak by 1 GiB. Cache of files the build only reads is reclaimed when the container reaches its limit; the output it writes is not, until it is flushed to disk. The output is small (41 MiB for 6,700 app modules), so size a memory.max by the process figures above plus that, not by memory.peak. The serve bundle's chunks are content-hashed, so a build whose shared modules or framework version changed leaves the previous build's chunk files behind; the build removes them afterwards and reports the count as prunedStaleChunks — that number is the previous build's output, not a leak.
voltro build takes a single optional app directory and parses no flags — the SSR bundle is always attempted, and the SSG pre-render always runs for static-mode pages.
Output layout
apps/acme/web/.framework/dist/
├── index.html # SPA shell fallback
├── about/index.html # pre-rendered static page
├── blog/first-post/index.html # SSG via getStaticPaths
├── assets/
│ ├── index-abc.js # main client bundle
│ ├── index-abc.css
│ └── island-LikeButton-def.js # per-island chunks (interactive: 'islands' pages)
└── server/
└── ssrEntry.js # SSR bundle for voltro start
Unresolvable optional peers
The SSR bundle inlines its dependency graph (ssr: { noExternal: true }). The
SSR, API serve and web start builds resolve packages using the actual bundler's
import/require conditions, from the importing module. An installed ESM-only
transitive dependency is therefore bundled even when CommonJS require.resolve
cannot resolve its export. No hoisting or direct app dependency is needed.
Outside the explicit native-runtime/build-tool handling in serve/start bundles,
Voltro explicitly externalises an unresolved package only when the importing package declares
it in optionalDependencies, or as a peer with
peerDependenciesMeta[name].optional: true, and it is actually absent. Missing
required dependencies, undeclared imports and broken exports of installed
packages fail the build when their resolution failure is unhandled. A declaration in the app cannot make another package's
required dependency optional.
Handled failures are a separate case. API serve and web start bundles preserve
esbuild's handling of require() inside try/catch and caught dynamic imports.
The source's fallback can run even without an optional-dependency declaration.
For example, mysql2's debug helper catches a missing cardinal syntax highlighter;
MySQL/MariaDB apps do not need to install it just to build. This also works with
production minification. Such imports are not reported as declared optional peers.
An unguarded missing import still fails the build; a caught import is not a
guarantee that the fallback itself succeeds.
Every specifier externalised by Voltro's declared-optional fallback is named on the success line:
[voltro:build] SSR bundle ready { path: 'dist/server/ssrEntry.js', externalizedOptionalPeers: 'bufferutil' }
Read that list: invoking an absent optional feature still fails at runtime, so
install its package if the app uses it. Do not mark a required import optional
just to make the build green. Framework packages (@voltro/*, @effect/*,
effect) are never externalised by this fallback. A successful build is not a
boot test: verify the relocated production artefact with its real runtime
dependencies, especially after pruning node_modules.
A CommonJS dependency in the SSR bundle — build.ssrExternal
voltro build bundles everything the SSR graph reaches into
dist/server/ssrEntry.js and tree-shakes it, so a production image carries the
artefacts and needs nothing from node_modules. That is the default and it is
what you want almost always.
A CommonJS dependency dragged into that ES module brings its __dirname /
__filename with it, and neither exists in an ES module. Nothing fails at build
time — the bundle is written and the build reports success — and the artefact
dies on its first import:
ReferenceError: __dirname is not defined in ES module scope
voltro build now fails when the bundle it just wrote references a
CommonJS-only global it never defines, and names the fix, rather than letting a
green build produce an image that cannot boot. Keep that one dependency a
runtime import:
// app.config.ts
import type { WebBuildOptions } from '@voltro/cli'
export default {
type: 'web',
build: { ssrExternal: ['pdfkit'] } satisfies WebBuildOptions,
}Only that package stays external; everything else is still bundled. It is
part of the image's runtime dependencies: install it there, not only in the
build stage. WebBuildOptions is exported in the CLI's published declarations;
satisfies checks the option and its string list without a runtime import.
Externalization is
deliberately not automatic: externalising on the framework's own initiative puts
a package back among the image's runtime requirements, so an image built on the
promise that it needs nothing from node_modules would fail to boot for a
second reason and you would have been told about neither.
voltro start <appDir>
voltro start apps/acme/web # serves the build output
PORT=8080 voltro start apps/acme/webWhat it does:
- Reads
app.config.ts.port(orPORTenv var) for the listen port. - Walks
dist/to discover pre-rendered HTML files. - Loads the SSR bundle from
dist/server/ssrEntry.jsand the route tree the build wrote beside it (dist/server/pagesTree.json). Falls back to Vite middleware mode if the bundle is absent — a local convenience, never a production path. - Starts an
http.Serverthat:- Serves pre-rendered HTML for matched URLs.
- Serves static assets from
dist/assets/,dist/_voltro/. - Renders SSR pages per request via the SSR bundle.
- Reads / writes ISR cache for
renderMode: 'isr'pages.
A production start reads the build, not the source. What a deployed web app needs is .framework/ (the build output, the precompiled appConfig.js and the route tree), app.config.ts and its node_modules — src/ is not read in production and can stay out of the image or the published package. An SSR bundle without the route tree beside it (a build from an older framework) is refused with the instruction to run voltro build again, rather than quietly falling back to walking src/pages.
Flags + env vars
| Flag / env | Notes |
|---|---|
PORT=8080 |
Override the listen port. |
SSR_CACHE=postgres |
Use the Postgres-backed ISR cache. Requires the web process to also have a database in its environment — DB_URL (what the templates set), DB_PRIMARY_URL, DB_HOST or PG_HOST. Without one, voltro start aborts on NODE_ENV=production/staging and warns loudly elsewhere; it no longer falls back to the in-memory cache in silence. Default is in-memory. |
VOLTRO_INSPECT=off |
Disable the inspect HTTP endpoints in production. |
VOLTRO_INSPECT_TOKEN=… |
Bearer token guard on the inspect endpoints. |
Per-request routing logic
For a request to /foo:
1. dist/foo/index.html exists? → serve it.
2. URL matches a static asset? → serve from disk.
3. URL matches a registered query?
- renderMode 'ssr' → render fresh via SSR bundle.
- renderMode 'isr' →
- cache HIT (fresh) → serve cached.
- cache HIT (stale) + staleWhileRevalidate → serve cached + bg refresh.
- cache MISS → render, store, serve.
- renderMode 'static' (no pre-render found) → serve SPA shell.
4. None of the above → 404 via not-found.tsx.The response includes a x-voltro-rendered-by header (prerender / ssr / isr) + cache state.
ISR cache backends
SSR_CACHE=memory voltro start # default — per-process, doesn't survive restart
# Postgres-backed cache — the web process needs a database in its env. Any of
# the usual variables works; DB_URL is what the templates set.
SSR_CACHE=postgres DB_URL=postgres://… voltro start
SSR_CACHE=postgres PG_HOST=… PG_PORT=… PG_USER=… PG_PASSWORD=… PG_DATABASE=… voltro startFor multi-instance + horizontal scale → Postgres. The cache table is auto-created on first boot.
This used to recognise
PG_HOSTand nothing else. An app configured the documented way —SSR_CACHE=postgresplusDB_URL— silently got the per-process memory cache, announced asisr cache backend: memory (per-process): an info line that reads like the default rather than like a refusal. Both the cache and the CDC invalidator go through the same connection resolver as everything else now, soDB_URL/DB_PRIMARY_URL/DB_HOST/PG_HOSTall work — andPG_SSLcomes with them. Asking for the postgres cache and getting memory is now a boot failure in production, not a log line.
Tenant-aware ISR
Pages with tenantAware: true get separate cache entries per tenant. The cache key becomes <pathname>|tenant=<tenantId>. See Render modes.
CDC invalidation
For pages with cacheInvalidatesOn: ['table', …], voltro start reads Postgres logical replication. Writes to listed tables invalidate every matching cache entry. Requires SSR_CACHE=postgres + wal_level=logical.
If routes declare cacheInvalidatesOn and the web process has no database in
its environment, boot now WARNS and names those routes — they fall back to plain
revalidate staleness. That gap used to be reported at debug, which is
invisible at the default level and indistinguishable from live invalidation
working.
Graceful shutdown
voltro start handles SIGTERM the way voltro serve does:
- Readiness flips to
503 not-ready, and the long-lived inspect streams are ended — their client reconnects, under a rolling update against the replacement pod. - The listener closes and in-flight requests finish, bounded to 60% of
VOLTRO_SHUTDOWN_GRACE_MS(default 10 s, clamped to 1 s – 5 min); the rest of the grace is for the closes behind it. - The SSR loader, the cache backend and the runtime-registry entry close, whatever survived the budget is ended, and the process exits
0— the log saysdrained in <ms>.
Set VOLTRO_SHUTDOWN_GRACE_MS just under your orchestrator's hard kill, and under Kubernetes add a preStop sleep with a terminationGracePeriodSeconds larger than sleep + grace — the manifest is on the production hardening page.
Health checks
voltro start binds its port as the first thing it does and answers three unauthenticated probe routes there from that moment — before the SSR loader loads, before the dist scan, through the whole boot:
GET /internal/liveness→200 ok— the process is up at all (restart the pod if it stops answering).GET /internal/readiness→200 readyonce boot completes,503 not-readybefore (pulls the pod from Service endpoints until ready) — and again from the moment SIGTERM lands.GET /internal/startup→ JSON{ phase, ready, elapsedMs },503until boot completes and200from then on, for astartupProbewhosefailureThresholdgives a slow boot its time.
Every other request on the port answers 503 with Retry-After: 1 until the boot completes; then the same socket serves the pages. Point the Kubernetes liveness probe at /internal/liveness, the readiness probe at /internal/readiness and the startup probe at /internal/startup. The probes are answered before the page router, so no route, no SPA shell and no auth redirect can answer them; health.path in app.config.ts moves the prefix. The same three paths mean the same thing under voltro serve and voltro dev.
Multi-instance + sticky sessions
For WebSocket connections to land on the same backend (required for in-process subscription state):
- Reverse proxy:
lb_policy ip_hash(Caddy) /ip_hash(nginx). - Cross-instance subscription invalidation is built in on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC); on any other dialect add
@voltro/plugin-broadcast(Redis/NATS). With that in place a reconnect may land on any replica and still sees every change — sticky sessions then only keep one live connection pinned, they are not a correctness requirement.
voltro build api --target typescript — the generated TypeScript client
The third consumer of the REST surface — a Node service, a partner backend, a pipeline script — gets a package of its own: the TypeScript client, generated from the same bindings as the native SDKs, standalone (it imports nothing from the app) and publishable as it is. Two flavors the consumer picks between — the Promise client without Effect at ., the Effect service at ./effect with effect an optional peer — typed errors, pages, a 429 as a wait, idempotency keys, live queries over the socket spoken without Effect.
voltro build api --target typescript --out ./sdk/ts --name @acme/api-client
voltro build api --target typescript --flavor plain --license MIT --version v2 --base-url https://api.example.com .--out <dir>— default<appDir>/sdk/typescript; anywhere, the package has no path into the app.--name <pkg>— the package name, default@app/api-client;--package-versionits version;--license <SPDX>its license field.--flavor plain | effect | both— the Promise client only, the Effect service only, or both entries (default).--version <v2>— one API version only;--base-url <url>the default base URL (elseVOLTRO_PUBLIC_API_ORIGIN).
Then cd sdk/ts && pnpm install && pnpm build && pnpm publish --access public — dist/ holds ESM + CJS + .d.ts.
The same pipeline writes API collections for the person who clicks before anyone codes: --target postman | bruno | insomnia | hoppscotch, with environments from servers, the bearer on the collection, example bodies, saved error responses and a test per request.
voltro build api --target <swift|kotlin> — native SDK generation
Generate a fully native mobile client from the same API bindings the TypeScript client is generated from. No hand-written models, no drift: the SDK is derived from your app's capability manifest — the exact procedure descriptors + JSON Schemas the framework already assembles from source — so every type stays in lockstep with the server.
voltro build api --target swift apps/acme/api # → apps/acme/api/sdk/swift (Swift Package)
voltro build api --target kotlin apps/acme/api # → apps/acme/api/sdk/kotlin (Kotlin Multiplatform)
voltro build api --target swift --out ./ios/Sdk --name AcmeClient .Flags:
--target swift | kotlin— the language to emit. Required.--out <dir>— output directory. Default:<appDir>/sdk/<target>.--name <PackageName>— the Swift package / Kotlin module name (PascalCase). DefaultVoltroClient.--kotlin-package <dotted>— Kotlin source package. Defaultcom.voltro.client.
What each package contains:
| Piece | Swift | Kotlin |
|---|---|---|
| Type-safe models | Codable structs + String enums |
@Serializable data classes + enum classes |
| One-shot client (query / mutation / action) | async throws methods over URLSession |
suspend methods over Ktor |
| Subscription client (streams) | AsyncThrowingStream over URLSessionWebSocketTask |
Flow over Ktor WebSockets |
| Auth + tenant context | AuthContext (bearer + x-tenant headers) |
AuthContext |
| Push registration | PushRegistration stub |
PushRegistration stub |
Type mapping is faithful: string → String, integer → Int, number → Double/Double, boolean → Bool/Boolean, arrays → [T] / List<T>, nested objects → their own named type, string-literal unions → an enum, and an optional field (one absent from the schema's required set, or a NullOr) becomes a Swift Optional / Kotlin nullable with a = nil / = null default.
Scope — this is the SDK code generator, not a native runtime. Deliberately out of scope (they need a native runtime or managed infra, not generated client code): native module bindings (camera, biometrics), the APNs/FCM push sender (per-tenant Apple/Firebase credentials, provisioned server-side), and the managed OTA / EAS build pipeline. The generated source is verified at the generator level (golden-string tests over the emitted Swift + Kotlin). Compiling it with swiftc / Gradle is the remaining step in your own mobile CI — the framework harness has no Swift/Kotlin toolchain.
voltro build api --target executable — the API as one launchable binary
Package the serve bundle voltro build already produced as a Node single-executable (SEA): one binary plus a serveBundle/ directory beside it, no node install and no @voltro/cli on the target machine. This is the desktop "Local Stack" shape — a Tauri app starts it as its sidecar and the web app talks to 127.0.0.1 — and it is equally a plain server artefact.
voltro build . # the serve bundle first
voltro build api --target executable . # → .framework/dist-api/executable/<app>-api
voltro build api --target executable --name acme --out dist/exe --no-strip .Flags:
--out <dir>— output directory. Default.framework/dist-api/executable.--name <binary>— the binary's file name (.exeis appended on Windows). Default<app>-api.--no-strip— keep the copiednodebinary unstripped (Linux strips by default, ~30 % smaller; stripping happens BEFORE injection because doing it after breaks the embedded blob).--json— the manifest on stdout.
What the output directory holds:
| File | What it is |
|---|---|
<name> |
the node binary this build ran on, with a launcher injected — the machine it runs on needs the same OS and CPU architecture |
serveBundle/ |
a copy of the serve bundle (entry + lazy app chunks); the launcher loads it from beside the binary, or from VOLTRO_EXECUTABLE_ROOT |
manifest.json |
binary sha256 and size, Node version, platform/arch, bundle size, and the native leaves the app reaches |
launcher.cjs, sea-config.json, sea-prep.blob |
the build's inputs, kept for inspection |
Run it exactly like voltro serve, from the app's directory: ./<name> serve . — .env, PORT, VOLTRO_SESSION_SECRET and the schema fingerprint gate all apply unchanged, and /internal/readiness answers as soon as the port is bound.
Native leaves are not inside the binary. A .node addon (better-sqlite3, sharp, …) cannot be inlined, so the bundle resolves it at runtime from the app's own node_modules — the manifest's nativeLeaves lists the ones this app reaches, and they ship beside the binary the same way they ship beside voltro serve. The build needs postject (an optional dependency of @voltro/cli) and, on macOS, codesign; it refuses with the tool's name when one is missing. The binary is built for the platform it runs on — build per target OS in that OS's CI job (voltro desktop ci generates the three).
Serving: asset prefix, pre-compression, sourcemaps, keep-alive
Four knobs that decide what leaves the container. All are voltro start
concerns; none change what your code does.
// apps/web/app.config.ts
web: {
// Serve `assets/` from a CDN. Becomes vite's `base`, so every emitted URL —
// entry, modulepreloads, CSS, images, fonts — is written with the prefix at
// BUILD time. Pre-rendered HTML still comes from the app; upload
// `dist/assets/` to the prefix on deploy. Filenames are content-hashed, so a
// previous deploy's assets stay valid for a visitor mid-navigation.
assetPrefix: 'https://cdn.example.com/_assets',
},
// Top level, NOT under `web:` — it covers every bundle the build minifies
// (browser, SSR, start, and an api app's serve bundle). Emits `.map` files with
// no `//# sourceMappingURL` comment, so nothing shipped points at them.
// `plugin-sentry` can upload and sweep them for you; `voltro start` refuses to
// serve a `.map` regardless, so a forgotten delete is not a leak.
sourcemaps: 'hidden',
http: {
// Node hangs up an idle keep-alive connection after 5s; every proxy in front
// holds one longer, and the request that lands in that window comes back as
// a 502. Default 72000 clears nginx-60/ALB-60; raise it above YOUR proxy's
// idle timeout. `headersTimeoutMs` must exceed it and is derived if omitted.
keepAliveTimeoutMs: 72_000,
},Pre-compression is automatic. voltro build writes .br (quality 11) and
.gz beside every content-hashed asset over 1 KB, and voltro start serves the
variant when the client accepts it. Measured on one 321 KB chunk, three requests:
| time | bytes | |
|---|---|---|
| compressed per request (before) | 5.6 / 5.0 / 4.6 ms | 100 665 |
| pre-compressed (now) | 1.6 / 1.7 ms | 86 083 |
Faster and smaller: a build can afford brotli q11 where a per-request path
cannot. Both encodings of one asset carry the same ETag — it is computed over
the uncompressed file, so a shared cache sees one representation.
A cross-origin api gets a preconnect. When an api's wsUrl is on another
host, the shell carries <link rel="preconnect" href="…" crossorigin> so DNS +
TCP + TLS overlap the bundle download instead of following it. Same-origin apis
are skipped — the browser already has that connection.
voltro serve <appDir> — API apps only
voltro serve is the production server for an API app. On a web app it
refuses and points you at voltro start:
`voltro serve` is the production server for an API app — this is a WEB app.
production voltro start .
development voltro dev .That is a refusal, not a missing feature. serve used to build a web app a
second time and hand it to vite preview, and both halves were wrong:
- In production it never ran. The launcher's serve fast path requires
.framework/dist-api/serveBundle/serveEntry.js— an artefact a web build does not produce — so a web app exited 1 pointing at a path that cannot exist, directly after avoltro buildthat had just succeeded. - Below production it destroyed the build. That second build had no
@tailwindcss/vite, no image pipeline and no per-page islands entries. With Tailwind it aborted; without it, it succeeded — and since both builds write.framework/dist, which Vite empties, it deleteddist/server, the island shells and every pre-rendered page.voltro startcould then not boot at all.
So the table is:
| app | development | production |
|---|---|---|
| web | voltro dev |
voltro start |
| api | voltro dev |
voltro serve |
voltro doctor — preflight a production serve
Production voltro serve for an API app boots ONLY from the precompiled serve
bundle and is fatal if it's missing — a hand-rolled Dockerfile that runs
voltro serve without a prior voltro build breaks at deploy. voltro doctor
(or voltro serve --preflight) catches that at BUILD time instead of cold-start:
voltro doctor . # check the serve bundle exists; print the fix if not
voltro serve --preflight . # same check, then exit — never bootsIt exits 1 when the serve bundle is missing on an API app (so it fails a CI /
Docker step) and prints the exact remedy: add a voltro build . step before
voltro serve .. Drop it into your image build right after voltro build to
guarantee the artefact is present before the image ships.
In a web app the preflight follows voltro start instead: it checks the built
shell (.framework/dist/index.html) and the SSR bundle
(.framework/dist/server/ssrEntry.js), and never asks a web app for the api's
serve bundle. It blocks under NODE_ENV=production or --strict, like the api
half.
Relations a query loads but does not declare
An eager-loaded relation is part of the RESULT, so its table has to be in source: or the view stops updating when it changes:
✗ 1 query loads a relation it does not declare:
tasks.getById: eager-loads `subTasks` from 'tasks' but does not declare
'task_sub_tasks' in `source:` — the view will not update when 'task_sub_tasks' changes.
This is the failure that looks like a broken feature and is not: the write lands, a reload shows it, and every test of the write path is green. The name in source: is spelled right and the table exists, so neither the typed source: nor the boot audit has anything to say.
The rule has no exception list, deliberately. It resolves .with({ … }) keys through the relation registry, so the missing table is a fact rather than an inference — and a many-to-many is reported twice when needed, because the junction table is where a link add/remove actually writes.
It reads what it can read literally: a computed .with() key yields nothing rather than a guess.
The store character set (MySQL / MariaDB)
The second rule that reads the database rather than the source: a text() and a json() column look identical in a schema file and are stored differently when the database default is utf8mb3 — the json column holds every character, the text column refuses an emoji with errno 1366. Doctor reads information_schema and names the table first:
• store charset (mariadb): no charset declared (tables inherit the database default); 1 table whose text columns cannot hold what a json column can:
- ticketCache: its json column holds every character (utf8mb4); these text columns do not — id (utf8mb3), description (utf8mb3)
declare the store's character set in app.config.ts — store: { dialect: 'mariadb' | 'mysql', charset: 'utf8mb4' } — and run `voltro db plan`: …
Three outcomes, never collapsed: not-applicable off the mysql family, not checked with the reason when no database is reachable, and otherwise the findings — against the declaration when store: { dialect, charset } is set (pointing at voltro db plan, which carries the conversion), or the json-versus-text disagreement when it is not. A healthy utf8mb4 database is one clean line. In --json the section is storeCharset. Advisory. See MariaDB → Character set.
The access-decision gate
Before the authz scan below — which is a heuristic over executor SOURCE — doctor
runs the same gate the boot runs: every wire-exposed procedure must declare
guards: or openAccess:. It is not advisory and not a ratchet, because a green
answer here means the app starts:
access decisions · security.defaultDeny ON
✗ no access decision 3
✓ openAccess, declared on purpose 2
pricing.current — public pricing page, reads no caller data
status.ping — health probe
✗ invoices.list (query)
src/api/invoices.query.ts
Every undecided procedure is listed — never a prefix — and the same set is in
voltro doctor --json under accessDecisions for a CI gate:
voltro doctor --json | jq '.accessDecisions.undecided[] | {tag, kind, file}'An app that sets security: { defaultDeny: false } still gets the list, marked
advisory, and doctor does not fail on it. Detail:
Authorization.
The authz scan
voltro doctor answers one mechanical question over every executor: does it
reference an access check at all?
authz scan · 586 executor(s)
✗ no access check 21
⚠ inline ownership check, no named guard 24 (informational)
✓ guards: on the descriptor 0
✓ calls a guard from the vocabulary 320
– accepted as recorded debt 221 (voltro-authz-allowlist.txt)
✗ teams.deleteSubTeam — deletes teams with nothing constraining WHICH row
api/teams/deleteSubTeam.mutation.server.ts
It scans queries and streams too, not only writes: an executor that takes an id and returns the row is the same hole as one that writes it. The exploitable shape is "acts on a row the client named, without comparing anything on that row to the caller", and a read has it.
It learns your guard names. An exported require* / assert* from your own
source counts as a guard, so requireTeamAccess() is recognised without any
configuration. Without that the scan would report every call site of your own
guards, which is the failure mode that makes a check ignorable.
It reads the executor's helpers as the executor. A database.<table> read
that lives in a lib/ helper the executor imports is attributed to the
executor (two hops of relative imports), so a query is never counted as
nobody's.
A reason is checked when it names a name. openAccess: 'enforced by requireTeamMember in the executor' names a guard from the vocabulary; the
hand-roll detector's open-reason-unverified reads the paired executor and
reports the sentence when the call is not there. A reason that names no guard
('public newsletter signup') is never flagged.
It reads your whole source tree for those names, not just the
convention-named files — guards live in lib/access.ts, not in *.mutation.ts.
The line above the counts tells you what it found, and it is worth reading before
you trust the numbers:
guard vocabulary: 17 from your source (requireTeamAccess, assertInquiryAccess, …)If it instead says framework names only — no exported require*/assert* found in this app while you know you export some, the counts below it are not meaningful:
every call site of your own guards is being reported as unguarded. Check that
they are exported and that the name starts with require / assert followed
by a capital.
An inline ownership check is informational. row.userId !== subject.id → new AccessDeniedError({}) is correct code — it is listed so you can see where the
rule lives in a handler rather than on a descriptor, and it never fails the run.
Findings are ordered by blast radius: a delete outranks an insert, and a
target table that is tenant()-scoped or referenced by other tables outranks one
that is neither.
The ratchet — how to adopt this on an existing app
A first run on a large app reports hundreds of handlers, and nobody triages hundreds of findings. So record them once and fail only on what comes after:
voltro doctor --write-authz-allowlist # writes voltro-authz-allowlist.txt
voltro doctor # exits 1 on anything NEWThe file is debt, not approval — every line is a handler nobody has confirmed is safe. It is keyed by rpc tag rather than path, so moving a file can neither re-open a hole nor hide one, and it is consulted last: an executor that gains a real guard is reported as guarded whether or not its line is still there. The list can only shrink unless someone adds to it deliberately.
Two kinds of line, because "debt" and "reviewed" are different claims. A bare
tag is debt. A tag with reviewed=<why> says a human read the executor and found
it genuinely open — constrained by something the scanner cannot see:
teams.deleteSubTeam
inquiries.publicFeed reviewed=public by design; returns only published rowsThe reason is required: reviewed= with no why is the claim without the
evidence, and doctor refuses it. A bare tag is always available and is the honest
alternative. The two are counted and printed apart, and
--write-authz-allowlist is additive: it keeps the existing file verbatim —
entries, comments, grouping, order — and appends only tags it does not already
contain. It cannot remove a line. Removing one is your edit, or it happens on its
own when an executor gains a guard and its line stops mattering.
Reading the whole list
The human view prints the 20 most severe unchecked executors. The complete scan
— every finding, the counts, the inferred guard vocabulary, and the debt/reviewed
split — is in voltro doctor --json under authz:
voltro doctor --json | jq '.authz.unchecked[] | {tag, why, path}'Nothing is truncated there. If you are triaging, work from the JSON.
Before you hand-roll another check
If your checks are imperative because a scope cannot express "may this subject
act on THIS row", that is what guards: [{ action, resourceType, resource }] is
for — and an app whose relationships live in its own tables (a teamMembers row,
say) registers its own tuple source instead of copying data into a framework
table. See Authorization.
The predicate-column check
eq / isNull / inSet are free functions, so the column name arrives as a bare
string and the builder cannot relate it to the table the predicate is attached
to:
database.teamAppointments.where(isNull('deletedAt'))
// ^ teamAppointments has no softDelete() mixin,
// so no `deletedAt` column exists. tsc: OK.That type-checks — review and CI pass — and then fails at runtime as a bare SQL
error. voltro doctor checks every literal predicate column against the table's
declared columns and names both:
✗ predicate columns: 1 filter on a column that does not exist (214 checked)
api/appointments/rollforward.query.server.ts:31 'teamAppointments' has no column 'deletedAt'
columns: id, teamId, startsAt, createdAt, updatedAt
This type-checks today and fails at runtime as a bare SQL error.
Matching is on the AST, never on text, so a column name in a comment or an unrelated string cannot trip it. A call site whose table cannot be resolved is skipped silently — an unresolvable receiver is usually not a table at all.
The type-level fix (binding the predicate to the row, where(c => isNull(c.x)))
is the right end state and is planned separately. This check is the half that
works retroactively: it finds the bug in code that already exists, which a
type change never will.
The detector also flags raw fetch() in server files. The SSRF guard the
framework ships lives in the HttpClient handlers yield* — so it protects
exactly the apps that already adopted it, and misses the ones that never did.
Those are usually the same apps that secured least elsewhere, which is why the
absence is worth naming out loud rather than assuming the default did its job.
The rule follows the IMPORT GRAPH, not the filename. Server-convention files
(*.server.ts, *.cron.ts, *.subscribe.ts, …) are the starting points, and
any file reachable from them and from nothing else counts as server code
too. That matters: keyed on filenames alone the rule caught 9 of 39 outbound
calls on the app that reported it — the other 30 sat in lib/*.ts helpers
(payments, an AI provider, TTS) imported only from server executors. A
lib/payments-mollie.ts is not client code, and no file extension can say so.
A helper a page ALSO imports stays unflagged, and that is the property keeping
this rule useful: fetch is unremarkable in a browser component, and flagging it
there would make the rule noise that gets scrolled past — taking the real
findings with it. Relative imports and your tsconfig paths aliases are both
followed.
The detector also flags an executor that never names its own descriptor.
Pairing is by FILENAME, which is right — and it means a *.server.ts can be a
complete, correct executor with no reference at all to the contract it
implements. Those are exactly the files where a hand-written input drifts from
the wire: in one reported codebase, six executors declared boardPurpose: string
where their own descriptor said Schema.Literal(...), discarding the contract at
the executor boundary. Fix by importing the descriptor and typing the input as
ExecutorInput<typeof descriptor>. Only a SIBLING import clears the finding —
an executor importing nothing but @voltro/* and node:* has still not named
its contract.
The workflows.start audit
voltro doctor also diffs every workflows.start(name, payload) call site
against the workflows the app actually registers:
✗ workflow starts: 13/14 call sites checked
api/crons/weekly.cron.ts:22 'sprint.report' payload is missing: teamId
api/crons/weekly.cron.ts:22 'sprint.report' payload has unknown field(s): scheduledAt
accepted: sinceIso, teamId
1 UNCHECKED (not verified — not a pass):
api/crons/digest.cron.ts:8 — payload spreads a value
Why this exists even though workflows.start validates at runtime. Runtime
validation fires on the next firing — which for a daily cron is hours, for a
weekly one is a week, and for a quarterly one is a quarter. And
voltro inspect schedules --failing cannot see a job that has never fired,
because its roll-up is built from recorded runs. A weekly workflow broken by a
refactor is invisible to both until it next runs.
UNCHECKED is never folded into a pass. A payload built with a spread or a
computed key can contribute any name, so its key set is unknowable here. Those
call sites are counted and listed rather than passed silently — 0 issues must
not be readable as "all verified". The workflow name is checked regardless,
since a rename or a deletion is decidable whatever the payload looks like.
The required keys come from the live payloadSchema, the same source the runtime
validation reads, so the two cannot disagree about what a payload needs.
The junction-FK check
A link / junction table (projectMembers, todoTagAssignments) exists to connect two aggregates, so its columns are almost all foreign keys. Declared with reference(() => projects) the framework knows the edge — it enforces integrity, auto-indexes the FK, and can walk the reference graph. Declared as a bare text() id column the same edge is invisible: no FK, no auto-index, and nothing that walks references can follow it. Nothing type-checks the difference.
voltro doctor flags a junction table with an id-shaped column that is a plain scalar and not a reference():
junction FKs: 2 junction tables with an id column that is a plain text() and not a reference()
'todoListMembers': 'todoListId', 'userId' (part of a composite primary key)
'todoTagAssignments': 'todoId', 'tagId' (this table is nothing but link columns)
Declare each as reference(() => <table>): the FK is enforced, the column is auto-indexed,
and the relationship becomes walkable (a plain text() id column is an invisible edge).
It will not fire on any *Id text column — a tenantId or traceId remains outside its junction signals. For an external-system identifier whose table really does look like a junction, declare text().externalId(); plugin-owned targets declared through pluginRef(...) carry the same exemption automatically. The marker records why no database FK can exist without changing storage or query behavior. The audit fires only when the table's OWN structure independently says "link table", and it names which signal tripped it so the finding is auditable rather than a bare accusation:
| Signal | What it means |
|---|---|
part of a composite primary key |
the suspect column is a member of an explicit primaryKey([...]) — the PK structure alone proves the row is a link |
sits beside a wired reference() on this table |
a real reference() on a same-shaped sibling column, while this one is a bare scalar |
this table is nothing but link columns |
the whole table is id-shaped columns + bookkeeping (a pure link table) |
The audit reads the tables' real declared ColumnTypes — the same materialised column definitions the migrator emits DDL from — never source text. So a reference is told apart from a plain scalar by its declared type, not a name regex, and a column name that only appears in a comment cannot trip it.
Event delivery + scale
Two events with identical route / subscriber / buffer numbers can mean opposite things about a missing message — each counts a drop as a loss and tells the subscriber, latest supersedes the pending value and says nothing — and that mode is invisible once the app is running. So voltro doctor lists every declared event's delivery mode:
event delivery: 4 declared events — the mode decides what a MISSING message means
'games.started': each
'player.moved': latest
each — every delivery matters; a slow subscriber loses the oldest and is TOLD how many (the default).
latest — a newer delivery supersedes a pending one; a slow subscriber gets the current value, told nothing.
It also warns on two shapes that will not scale the way the declaration reads — advisory, never blocking:
| Warning | Why |
|---|---|
| routing key has 3+ fields | every key field is a routing address, and the count of distinct routes is the product of the fields' value spaces. Check each is an ADDRESS the delivery is decided by (arenaId), not a discriminator the handler reads (gameType) — the latter belongs in the payload, not the key. |
webhook: on a per-frame event |
a webhook block on a name like player.moved / cursor.moved / *.frameRendered becomes N HTTP deliveries per second per subscribed target. The webhook rate limit defers the excess as pending rows rather than failing, so the symptom is a growing table. Publish a coarser event (a summary / state change) for the outside world. |
The field count comes from the same schema-property reader the runtime validation uses, so it cannot disagree with the key the event actually routes on. Both findings appear in voltro doctor --json under eventDelivery.
The hand-roll detector
voltro doctor also scans your source for shapes the framework already has a
primitive for, and names the primitive at the spot the hand-roll lives. This is
advisory and never blocking — it prints, it does not fail your build.
Three of its rules read the SERVER side, where a hand-roll is a hole rather than a duplication:
open-reason-unverified— anopenAccessreason that names a guard from the vocabulary, on a descriptor whose executor never calls it.input/uncapped-array—Schema.Array(...)in a mutation or action input with nomaxItems; the body limit bounds bytes, not writes. Silent oninternal: true: that procedure is off the wire, so no caller chooses how many.cron/bucket-on-wall-clock— a cron that keys its period onfiredAt,Date.now()ornew Date()and never readsscheduledAt, so a late fire lands in the next bucket. The scan follows value references into relative app helpers, including renamed imports, namespace members and re-exports; an importedmidnightUtcForToday()therefore counts. The finding names the cron, also in--json. Unused helper exports, comments, strings and types do not count. This is a static advisory: a referencedscheduledAtread clears it, but does not prove the bucket uses that value. Package imports, tsconfig aliases and dynamic imports are not followed; no finding is not proof of correct bucketing. A cron without any clock read is not flagged just because it omitsscheduledAt.mutation/target-op-mismatch— a mutation whosetarget: { table, op }promises one write verb while its executor performs another on that table.bulk-write-in-loop—insert/insertIgnore/upsertinside a loop or per-item callback, whereinsertMany/insertManyIgnore/upsertManyis one statement per batch.mutation/read-modify-write-unversioned— an executor that reads a row, rebuilds it in memory and writes it back with a keyedupdateon a table with no.version()column; a concurrent edit of that row is overwritten in silence, and the transaction does not change that.
voltro doctor .• Shipped primitives you may be hand-rolling:
[server]
hand-written not-found branch on rows[0] — 12 file(s): queries/team.get.ts, …
→ .one() — fails with the typed NoRowFound on zero rows AND on more than one
[client]
per-field useState + a submit flag (hand-rolled form) — 4 file(s): src/create-dialog.tsx, …
→ useFormBinding — fields + validation from the mutation input SchemaIt covers both halves of the stack:
| Scope | It notices | Reach for |
|---|---|---|
| server | an id-constrained singleton store.query followed by if (!rows[0]) throw … |
.one() / .first() |
| server | 3+ sequential store.query in one function |
relations() + .with() — or Effect.all |
| server | Effect.promise(() => ctx.store.<effect-method>(…)) |
yield* EffectStore |
| server | requireScope(...) at the top of an executor |
guards: on the descriptor |
| server | a token / secret / password column with no encryption |
.encrypted() |
| server | a notify / webhook helper called at a *.mutation.server.ts executor's tail |
defineSubscriber / defineReaction |
| server | a *.subscribe.ts handler that writes or publishes, with neither once: nor eachReplica: true |
choose one explicitly on defineSubscriber |
| server | an executor builds a field its descriptor's output does not declare |
add it to output — the struct IS the serializer |
| server | a mutation UPDATE writes a NULLABLE column through an input field that cannot be null |
Schema.optional(Schema.NullOr(...)) |
| server | hasMore + limit + 1 |
paginateById |
| server | .getTime() / .toISOString() placed directly into returned wire output |
timestampMs / timestampMsOrNull from @voltro/database/wire in the descriptor's output struct |
| client | 4+ per-field useState, a mutation, and submit handling |
useFormBinding |
| client | a query-backed table with local sort/filter state | useDataTable |
| client | FileReader / readAsDataURL |
useUpload |
| client | a debounce-named setTimeout/clearTimeout pair in a useEffect |
useDebounced |
| client | useMemo fanning in several subscriptions |
useDerived |
| client | a local Next.js compat shim | the native @voltro/web exports |
| client | a hand-rolled roster/typing/cursor presence heartbeat | @voltro/plugin-presence |
| client | data === undefined / !data on a subscription result |
branch on loading (and idle, if you pass skip) |
The subscription rule resolves the binding rather than matching text, and
that distinction is the reason this scanner parses at all. A deployment migrating
these call sites wrote a regex codemod for the same job, and it rewrote a
summary === undefined check inside a child component where summary was a
PROP. Their compiler happened to catch it, because that name was out of scope
there; had the names matched, a silent behaviour change would have shipped. Text
cannot tell you which declaration an identifier refers to — so a rule about
identifiers has no business being written in text.
The rules are deliberately conservative — a detector that cries wolf trains you
to ignore it. A column that already carries .encrypted(), or a handler that
already uses .one(), stays silent.
Two of them are worth spelling out, because their advice is not one-line:
The subscriber rule is the second half of the mutation-tail rule. That one
moves an effect OUT of a mutation and into a subscriber, which is right — and
lands it on a channel every replica listens to. store.onChange is a broadcast:
correct for a READER (a cache drop, an index refresh, a live query must run
everywhere) and a multiplier for an EFFECT, because there is nothing to make
idempotent — the effect IS the write, so each run produces another one. One
INSERT behind two replicas therefore writes two notification rows, and four
with a broadcast bus in front.
So the rule fires when a handler WRITES (ctx.store.insert / update /
upsert / …), publishes (ctx.publish), or calls a notify / sendWebhook /
sendMail-shaped helper, and the subscriber declares neither once: nor
eachReplica: true. Either declaration silences it, because the question is
whether the topology was chosen explicitly, not which way. It reads the handler through the AST, so
handler: notifyApprovers naming a function in the same file is judged exactly
like an inline arrow; a handler IMPORTED from another module is not judged at
all, since its body is not in the file being read.
Two rules read a DECLARATION against a DECLARATION, and are therefore exact.
executor-builds-an-undeclared-field compares the object literal an executor
returns with its descriptor's output struct: since the struct IS the
serializer, a key it does not declare is stripped on the way out, so every
reader downstream gets undefined and renders a blank with nothing logged
anywhere. nullable-column-a-mutation-cannot-clear compares a mutation update's target
table's nullable columns with its input schema — a nullable column written
through a field that cannot be null can be set once and never emptied.
Both REFUSE rather than guess, and the refusals are the interesting part. A
spread on either side of the output comparison ({ ...row, extra }, or a
...Base inside the declared struct) means the key set is not knowable from the
source; reporting the visible half would name the field the author can already
see and miss the ones they cannot. An output that is a named schema rather than
a literal struct is unjudgeable, not empty — the second reading would make every
field a finding. Inserts and command-style writes are not described as
"clearable" merely because their input omits null. And a table declaration the scan cannot find produces no
finding at all, because "this table has no nullable columns" and "I could not
look" lead to opposite conclusions.
It stays quiet on a reader on purpose. once: on a cache-warming subscriber
would silence it on every replica but one, which is worse than the repetition it
removes — only the handler's author knows which of the two they wrote.
The credential-column rule skips names that aren't credentials. A name ending
in Id / _id, a name ending in Hash / _hash, and a name beginning with
vault are all left alone:
| Column | Why it's skipped |
|---|---|
jiraSecretId, token_id |
an IDENTIFIER of a secret held elsewhere, not the secret |
apiKeyHash, password_hash |
the hash IS the protection — encrypting it is nonsense, and it breaks the column as a unique lookup key |
vaultToken |
a HANDLE into a secret store, naming a secret held elsewhere |
The suffix tests use a camelCase / underscore boundary on purpose: a blind
/id$/i would also swallow apiKeyValid, while tokenIdentifier — which ends
in neither — must still fire.
The sequential-reads rule names TWO levers, and the criterion for choosing.
The detector groups calls by their owning function, ignores independent reads
already inside Effect.all / Promise.all, and never combines one query from
each of several helper functions into a fictional sequence. For a genuine
sequential group, both shapes chain later reads off earlier results, so no
text-level heuristic can split them — you make the call:
- The reads are a parent → child walk on ONE key → declare
relations()in a*.relations.tsand collapse them into.with({ … }): one JSON-aggregate query, on every dialect. - The reads collect ids from SEVERAL sources (JSON-array references, a
junction carrying extra columns, JS-side sorting) → keep the assembly and run
the independent LEADING reads under
Effect.all. Same queries, same results, only concurrent — zero parity risk.
The second case is the common one. Measured on a real 74-hit codebase, about two
handlers were clean full-parity relations() conversions and the other ~72 were
multi-source assemblies where .with() covers only part of the work or subtly
changes behaviour. Prescribing relations() for all of them would be wrong ~97%
of the time — and advice that is usually wrong trains you to ignore the finding.
The human view shows the first three file paths per finding and says how many it
withheld. Those paths are the actionable part — a count you cannot turn back
into a work list tells you the size of the problem, not how to fix it — and the
matching rule lives inside the CLI, so you cannot re-derive the list with your
own grep. --json prints the complete scan, nothing elided, with no preflight
output mixed in:
Unimported @voltro/* dependencies
A declared framework dependency nobody imports still gets installed, walked on
every voltro update, and read as evidence the package is in use — its
breaking-change notes included. The usual origin is a migration: the app moves
off a framework package to a third-party one, and the package.json entry
stays. voltro doctor checks every @voltro/* in dependencies and
devDependencies for an import site:
unimported deps · 8 @voltro/* package(s) declared, 214 file(s) scanned
⚠ @voltro/i18n — declared in dependencies, imported nowhere
a dependency nobody imports still gets installed, updated, and read as
evidence the package is in use — its breaking-change notes included.
Remove it, or if it IS imported through an assembled specifier the scan
cannot see, keep it and ignore this line — the rule is advisory.
· (2 loaded by the framework itself: @voltro/cli, @voltro/sql-postgres)
Scoped to @voltro/* deliberately: for third-party packages the same question
has a long tail of legitimate no-import shapes, and a rule that is sometimes
wrong is one people stop reading. Three states are distinguished, and each is
printed:
- Exempt, by name — packages the framework loads on your declaration
(
@voltro/cliis the binary;@voltro/devtoolsis mounted byvoltro dev; the@voltro/sql-*dialect drivers are loaded from your config). An exemption you cannot see is a finding you cannot question. - Not measurable yet —
@voltro/client/@voltro/webare normally imported by generated code. On a tree where codegen has never run, their absence is a missing measurement, not a dead dependency; the section says so and tells you to runvoltro devonce. - Unimported — advisory, never fatal. A mention in a comment or an error string does not count as an import (a commented-out import is exactly the residue this looks for), and an import assembled at runtime from string pieces is invisible to the scan — the finding text says both.
The full report is in voltro doctor --json under unimportedDeps
(null when there is no package.json to read — "could not check" and
"checked, clean" never print the same).
Duplicate package instances
voltro doctor also reports any identity-sensitive package resolved at more than
one version — effect, @effect/*, @voltro/*, react/react-dom:
• 1 package(s) resolved at more than one version:
effect — 3.18.4, 3.21.0
node_modules/effect
../../node_modules/effect
This is worth its own check because of how it PRESENTS. Effect's types are
nominal, so a Schema built by one copy is not the type the other expects, and
the errors land in the GENERATED rpcGroup.generated.ts — a file you cannot edit
and did not write:
Property '[TypeId]' is missing in type … Schema<any, any, unknown>
Type 'typeof Never' is not assignable to type 'All'
Argument of type 'Rpc<…, Stream<…>, …>' is not assignable to 'Any'
Read cold, that says "the framework emits bad types". It says nothing about the
dependency tree, which is where the problem is. And the RUNTIME usually stays
green — two instances only diverge where identity matters — so the app boots,
serves and passes its tests while tsc is red.
Fix it in the install, not the code: align the version across the workspace (a
root pnpm.overrides / resolutions entry for effect is the blunt
instrument), then reinstall. Do NOT add @ts-nocheck to the generated file — it
is exactly where a genuine mistake in your own descriptors surfaces.
voltro doctor . --json # the complete scan: every file path, machine-readableThe hand-roll scan indexes identifier names once per source-file AST before resolving matching bindings. It still checks declaration identity, so same-named shadow variables remain separate and findings retain every matching file. Form checks skip binding resolution when no submit path is present; scan scope and JSON output stay unchanged.
{
"root": "/app/api",
"scannedFiles": 214,
"scannedDirs": ["queries", "mutations", "database"],
"findings": [
{
"id": "row-not-found",
"scope": "server",
"smell": "hand-written not-found branch on rows[0]",
"use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
"files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
}
],
"spaCandidates": []
}That is the form to hand an agent, or to pipe into a script that works the list file by file.
renderMode:'spa' candidates
voltro doctor also flags web pages that could adopt renderMode: 'spa' without
losing their server-rendered shell. A page under a layout renders that LAYOUT
chain on the server — nav, sidebar, auth gate, via the layout's own loader — even
when the page itself is 'spa'. So a page whose BODY needs no SSR can skip its
per-page SSR compile while the shell still server-renders. Like the hand-roll
detector, this is advisory and never blocking.
A page is listed when ALL of these hold:
- it is a page file — not
layout.tsx/loading.tsx/error.tsx/not-found.tsx; - it exports no
loader(so'spa'loses nothing the page contributed server-side); - its
renderModeis'ssr'or unset/default — not a page that already opted into a non-SSR render ('spa'/'static'/'isr', or any other explicit mode); - a
layout.tsxsits somewhere in its directory chain — root, an ancestor, or the page's own dir. This is the load-bearing condition: only then does a layout still SSR the shell. A page with no layout would, as'spa', ship no server HTML at all — so it is never flagged.
• renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
src/pages/dashboard/page.tsx (/dashboard) — default renderMode
declares no renderMode, so it is server-rendered by default — every request renders and hydrates this leaf, yet it has no loader, so its data arrives client-side anyway; 'spa' keeps the layout's shell server-rendered and drops only this leaf to the client (less server CPU per request, no SSR compile for it, and no SEO cost for a page whose content was never in the HTML)
src/pages/admin/settings/page.tsx (/admin/settings) — renderMode:'ssr'
declares renderMode:'ssr' — every request server-renders this leaf and hydrates it, yet it has no loader, so its data arrives client-side anyway; 'spa' keeps the layout's shell server-rendered and drops only this leaf to the client (less server CPU per request, no SSR compile for it, and no SEO cost for a page whose content was never in the HTML)
→ renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.Adopt 'spa' for internal or authenticated pages whose content needs no SEO or
server first-paint; keep 'ssr' (or the 'static' default) when it does. There
is deliberately no codemod to flip pages automatically — dropping a page
body's server render is a per-page product decision, not a mechanically-safe
transform. Every candidate (with its file, pattern, and currentMode) is
also in voltro doctor --json under a spaCandidates array; the human view
above caps at ten pages and points to --json for the rest.
voltro doctor --pwa
The web app's installability preflight, as a list of findings rather than a score: the pwa block's problems, the icon source, and — once dist/ exists — the same static audit voltro pwa audit runs over the built manifest, icons, worker, offline page and head tags. An app without a pwa block gets one warning and exit 0; a blocking finding exits 1. --json prints the report.
voltro capabilities — what the framework actually exports
Asked "what does this framework export", a language model will produce a
confident answer whether or not it knows. This command replaces that guess with
a reading of the .d.ts files in your own node_modules:
voltro capabilities # human summary, grouped by package
voltro capabilities --json # the full machine-readable surfacevoltro capabilities — 3856 exported symbols across 36 packages
@voltro/runtime@0.38.0 — 12 primitives, 5 hooks, 100 components, 360 values, 388 types
defineAggregate, defineConnection, defineCostBudget, defineEventTrigger, defineExecutor, …
@voltro/database@0.38.0 — 3 primitives, 33 components, 333 values, 223 types
defineMigration *, defineMixin, defineSeed *
* 9 primitive(s)/hook(s) appear nowhere in this project's agent guide:
@voltro/database: defineMigration
@voltro/plugin-flags: defineFlag
…The count is the packages installed in that project, not everything the framework publishes — a leaner app reports fewer.
Every symbol reported was read out of an installed package a moment ago, so an
agent can verify the surface instead of recalling it. The --json form is
stable and locale-independent — the same tree produces byte-identical output on
every machine, so you can diff it across upgrades.
Symbols marked * ship but appear nowhere in this project's seeded AGENTS.md
/ CLAUDE.md. Refresh the guide with voltro agents-md --force, or read that
package's README.
Anti-patterns
- Running
voltro startagainst a directory withoutdist/. It exits 1 with a clearno built dist found — run voltro build first(checked against.framework/dist/index.htmlbefore any heavy work). Runvoltro buildfirst. voltro startin dev to "test prod". Usevoltro build && voltro start. The dev server has different behaviour; serving dev artefacts via start is undefined.- Skipping the SSR bundle build. Middleware mode is slower (cold-start cost on every render). For production deploys with
renderMode: 'ssr'pages, build the bundle.
See also
- Render modes — which mode produces which output
- Voltro Cloud — managed
voltro start+ autoscale (coming soon) - Self-hosting — Docker + reverse proxy patterns
Reviewed Doctor findings
Create voltro-doctor-reviews.json at the app root to record a deliberate decision about a smell rule. Each entry names one exact rule and one exact app-relative file, with a reviewed reason of at least 12 characters. Wildcards, unknown rules and malformed entries cannot hide findings.
[
{
"rule": "offset-pagination",
"file": "src/queries/todos.completed.query.server.ts",
"reviewed": "The product requires jumping directly to numbered pages."
}
]The human report prints the reason; --json exposes reviews (active/stale) and reviewIssues. The selected finding leaves the actionable list. A decision becomes stale when its finding no longer matches and should then be removed. Reviews apply to the file/rule pair, so re-review them when that implementation changes. They do not waive serve preflight or authorization gates.
Doctor counts controlled form fields instead of all local state, and only recommends useDerived for memos that read subscriptions. Null comparisons and truthiness guards are not classified as undefined loading checks. Singleton rules follow the store-result binding; sorting an array in memory is not a singleton lookup. Frontier and cursor loops are not automatically N+1. Concurrent per-row queries still are N+1: Promise.all changes latency, not query count.
Mapping or reducing a completed query result is not a per-row database read:
(yield* store.query(...)).map(...) performs one query. The N+1 check only
counts reads inside the callback body or a real enclosing loop. This also
applies to flatMap, forEach, and reduce; actual callback-body reads remain
findings, including when the callback post-processes another query result.
A zero-finding report is not a quality target. Export-pair tests establish discovery contracts, not executor behavior; retain tests of authorization, failures and business outcomes.