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-backend

What 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 export

No 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 execute

Mutation — 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 execute

Database

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-backend is 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.ts half.
  • Dropping the cross-tenant write guard. Tables with tenant() get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs assertOwnTenant(input.tenantId, ctx.request.subject) — without it a client authenticated as A can submit tenantId: '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.