API · Backend
The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box.
The minimal but complete api template: an app.config.ts, a one-table schema, one streaming query, and one tenant-guarded mutation. It's the smallest reference that shows the framework's core file convention — the descriptor / .server.ts executor split — and the tenant-scoping guard. Start here when you're not sure which api template fits. Template id: api-backend.
Scaffold
voltro create-project acme --api=api-backendWhat ships
apps/acme/api/ # dir named by the app, not the template
├── app.config.ts # type:api, name, store:'memory'
├── package.json
├── tsconfig.json
├── README.md
├── database/
│ └── schema.ts # actors + tenants (core) + a notes table
├── queries/
│ ├── notes.query.ts # descriptor — browser-safe
│ └── notes.query.server.ts # server executor — default export
└── mutations/
├── notes.create.mutation.ts # descriptor — browser-safe
└── notes.create.mutation.server.ts # server executor — default exportNo actions, workflows, agents, or AGENTS.md ship — this template is deliberately the smallest working shape. The mail / storage / mariadb variants build on this same notes base.
The descriptor / .server.ts split
Every query / mutation / action is two files paired by basename: a browser-safe descriptor (*.query.ts / *.mutation.ts) holding defineQuery / defineMutation, and a server-only executor (*.query.server.ts / *.mutation.server.ts) with a default export. The framework pairs them at boot. The descriptor file must never import node:* / database / runtime modules — it's what the web client pulls through codegen.
Query — queries/notes.query.ts + .server.ts
// notes.query.ts — descriptor (browser-safe)
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const listNotes = defineQuery({
name: 'notes.list',
input: Schema.Struct({}),
output: Schema.Struct({
id: Schema.String, title: Schema.String, body: Schema.String,
done: Schema.Boolean, tenantId: Schema.String, createdAt: Schema.Date,
}),
})// notes.query.server.ts — executor (default export)
import type { AppContext } from '@voltro/runtime'
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
descriptor: {
table: 'notes' as const,
order: [{ column: 'createdAt' as const, direction: 'desc' as const }],
take: 100,
// tenant scope is AND-merged by the runtime — no manual eq('tenantId', …)
},
})
export default executeMutation — mutations/notes.create.mutation.ts + .server.ts
// notes.create.mutation.ts — descriptor
import { defineMutation } from '@voltro/protocol'
import { TenantMismatch } from '@voltro/plugin-multitenancy'
import { Schema } from 'effect'
export const createNote = defineMutation({
name: 'notes.create',
target: { table: 'notes', op: 'insert',
shape: (input: { tenantId: string; title: string; body: string }) => ({
title: input.title, body: input.body, done: false,
tenantId: input.tenantId, createdAt: new Date(),
}) },
input: Schema.Struct({ tenantId: Schema.String, title: Schema.NonEmptyString, body: Schema.String }),
output: Schema.Struct({ /* id, title, body, done, tenantId, createdAt */ }),
error: TenantMismatch,
})// notes.create.mutation.server.ts — executor
import { assertOwnTenant } from '@voltro/plugin-multitenancy'
import type { AppContext } from '@voltro/runtime'
const execute = async (input: { tenantId: string; title: string; body: string }, ctx: AppContext) => {
// Cross-tenant write guard — input.tenantId MUST match the subject's,
// else the rpc layer surfaces a typed TenantMismatch.
assertOwnTenant(input.tenantId, ctx.request.subject)
// The framework auto-injects a `note_…` TypeID from the table's id() decl.
return ctx.store.insert('notes', {
title: input.title, body: input.body, done: false,
tenantId: input.tenantId, createdAt: new Date(),
})
}
export default executeDatabase
The shipped schema declares the two framework core tables plus one example table:
// database/schema.ts
import { boolean, databaseHandle, id, table, text, timestamp, type InferRow } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
// Core tables — required by the audit / tenant mixins.
export const actors = table('actors', {
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
displayName: text().nullable(), createdAt: timestamp().default('now'),
})
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
// Application table — rename / replace with your own.
export const notes = table('notes', {
id: id({ prefix: 'note' }), title: text(), body: text(), done: boolean().default(false),
})
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
.reactive() // subscriptions get a fresh snapshot/delta on every write to notes
export type Note = InferRow<typeof notes>
export const database = databaseHandle({ actors, tenants, notes })tenant() makes the table tenant-scoped: the runtime AND-merges eq('tenantId', subject.tenantId) into every subscription predicate, so cross-tenant reads can't leak.
Store — memory by default
app.config.ts ships store: 'memory' — fast startup, no Docker, data resets on restart. Good for dev, tests, and a first impression. Switch to a real SQL backend by setting the dialect + connection env (or store: in app.config.ts):
DB_DIALECT=postgres DB_URL=postgres://app:app@localhost:5432/acme voltro dev .voltro dev against a SQL store auto-migrates the discovered schema before any handler boots. See the SQL dialects guide for mysql / mariadb / mssql / sqlite / turso.
Discovery
voltro dev . walks the app for the file conventions — *.query.ts + *.query.server.ts, *.mutation.ts + *.mutation.server.ts, *.action.ts + *.action.server.ts, *.workflow.tsx, *.cron.tsx, schema.ts / *.entity.ts — pairs each descriptor with its .server.ts executor, and rewrites rpcGroup.generated.ts (the typed client). Drop more files anywhere in the tree; nothing is registered manually.
When to use api-backend vs. the variants
| You need… | Pick |
|---|---|
| The smallest reference to extend | api-backend |
| Transactional email wired (React-Email) | api-backend-mail |
| File storage wired (public + private objects) | api-backend-storage |
| MariaDB binlog CDC + storage (K8s shape) | api-backend-mariadb |
All four share this notes base; the variants just wire a plugin / store on top.
Pairs well with
- Any web template —
api-backendis the most generic api. @voltro/plugin-auth— wire it in for real sign-in.
Anti-patterns
- Putting
node:*/ database imports in a descriptor (*.query.ts). Those files reach the browser bundle through codegen. Keep server-only code in the.server.tshalf. - Dropping the cross-tenant write guard. Tables with
tenant()get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needsassertOwnTenant(input.tenantId, ctx.request.subject)— without it a client authenticated as A can submittenantId: 'B'and the row lands in B's data. - Discovery walker as a security boundary. It picks up everything matching the pattern. Every discovered descriptor is exposed via the rpc client — don't put secrets in one.