Services — apps that call each other
One app, one database. When a second app of the project needs the first one's data, it CALLS it — `service:` procedures on the owner, `apis:` and `ctx.apis` on the caller — typed, authenticated per edge, and pushed to it reactively.
Most projects are one api app, scaled by running more replicas of it. That is still the default, and scaling is not a reason to split an app.
A separate app is right when something has its own data or credential boundary — a worker that holds the cloud provider's keys for every tenant should not be the process that answers requests from the internet. When you split, the rule is absolute:
Every api app owns its database. An app that needs another app's data calls that app — it never reads or writes the other app's tables.
The framework enforces both halves: a second app booting against a database another app owns refuses to start, and a store call on a table the app does not declare fails at the call. What the rest of this page describes is the way two apps talk instead.
The callee: service: procedures
The app that owns the data declares which of its procedures another app may call, and which apps may call them:
// apps/cloud/api/mutations/deployments.activate.mutation.ts
import { defineMutation } from '@voltro/protocol'
import { Schema } from 'effect'
export const activate = defineMutation({
name: 'deployments.activate',
service: { callers: ['worker'] },
input: Schema.Struct({ deploymentId: Schema.String, tenantId: Schema.String }),
output: Schema.Struct({ activated: Schema.Boolean }),
})// apps/cloud/api/app.config.ts
export default {
type: 'api' as const,
name: 'api',
serviceCallers: { worker: {} },
}service: is an access decision of its own — the fourth, beside guards:,
openAccess: and internal:. It satisfies security.defaultDeny, and it means
exactly what it says:
- only the listed apps reach the procedure. No browser session, no API key,
and no
admin:fulluser does — a machine contract between two apps is not a door for a person; - the listed apps reach nothing else. A service credential opens the procedures that name it and is refused at every other one, framework built-ins included.
Codegen emits service procedures into serviceGroup.generated.ts — the typed
contract the calling app imports — and never into the browser's
rpcGroup.generated.ts, so no client bundle ships a machine contract's schemas.
Export it from the app's package beside ./rpcGroup:
{
"name": "@acme/api",
"exports": {
"./rpcGroup": "./rpcGroup.generated.ts",
"./serviceGroup": "./serviceGroup.generated.ts"
}
}A caller is named by its app name: letters, digits and dashes, starting with
a letter, at most 56 characters (it writes as service:<name>, an actors id).
service: cannot sit beside guards:, openAccess:, internal: true,
publicApi, exposeAsTool, requiresApproval or overridesPlugin — each is
refused where you write it.
Whose data a service procedure sees
The handler runs as the calling SERVICE. A service works across tenants, so it
reads without a tenant scope and must NAME the tenant it writes into —
ctx.storeForTenant(input.tenantId) is the shape for per-tenant work:
// apps/cloud/api/mutations/deployments.activate.mutation.server.ts
import type { AppContext } from '@voltro/runtime'
const execute = async (input: { deploymentId: string; tenantId: string }, ctx: AppContext) => {
const store = ctx.storeForTenant(input.tenantId)
await store.update('deployments', input.deploymentId, { status: 'ready' })
return { activated: true }
}
export default executeAudit records the caller as service:worker. The called app plants an actors
row for every accepted caller at boot (id: 'service:worker',
kind: 'serviceAccount'), so the audit() columns createdBy / updatedBy
name the calling app on every row it writes.
The caller: apis: and ctx.apis
The calling app binds the other app under apis: — the same declaration a web
app uses for its api, on an api app:
// apps/cloud/worker/app.config.ts
export default {
type: 'api' as const,
name: 'worker',
apis: {
cloud: { package: '@acme/api' },
},
}Codegen writes apis.generated.ts beside the app's own groups: it imports each
callee's ./serviceGroup and types ctx.apis.cloud from the callee's own
declarations. Commit it with rpcGroup.generated.ts. Its heart is one module
augmentation:
// apps/cloud/worker/apis.generated.ts — written by codegen, never by hand
import type { ServiceApi } from '@voltro/protocol'
import type { ServiceProcedures as CloudProcedures } from '@acme/api/serviceGroup'
declare module '@voltro/runtime' {
interface AppApis {
readonly cloud: ServiceApi<CloudProcedures>
}
}// apps/cloud/worker/schedules/activate.cron.tsx
import { defineSchedule } from '@voltro/runtime'
export default defineSchedule({
name: 'activate',
cron: '*/30 * * * * *',
timezone: 'UTC',
handler: async (ctx) => {
await ctx.app.apis.cloud.deployments.activate({ deploymentId: 'dep_1', tenantId: 'acme' })
},
})Every input, output and typed error comes from the callee. A call:
- fails with
ApiUnavailablewhen no answer arrives — the socket is down, the deadline (10 s by default,{ timeoutMs }per call) passed. A write whose earlier send is still in flight on the callee (MutationInFlight) is re-sent with the same key until it answers or the deadline passes — that too is no answer yet. It is the only failure worth retrying. Every answer the callee gives — a declared error,ScopeError,Unauthenticated— arrives as itself; - connects lazily. A callee that is down makes calls fail; it never stops the caller's boot;
- is measured as
voltro_api_call_duration_secondsandvoltro_api_call_errors_total, by binding, tag and outcome.
A query called once resolves to its first snapshot. To hold it open, use
.subscribe(input, { onValue }) — or, for the common case of reacting to
another app's open work, a reaction that watches it.
Inside a workflow step: .effect — and a write happens once
A step that is retried after its reply was lost sends the same call again. So a write made inside a step carries an idempotency key derived from the step — its durable execution, its name and the call — and the second send replays the recorded answer instead of writing twice:
// apps/cloud/worker/workflows/deploy.run.workflow.server.tsx
import { step } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import type { AppContext } from '@voltro/runtime'
const buildExecute = (ctx: AppContext) =>
(payload: { readonly deploymentId: string; readonly tenantId: string }, _executionId: string) =>
Effect.gen(function* () {
return yield* step({
name: 'activate',
success: Schema.Struct({ activated: Schema.Boolean }),
execute: ctx.apis.cloud.deployments.activate.effect(payload).pipe(Effect.orDie),
})
})
export default buildExecuteAn app with serviceCallers deduplicates mutations by default for exactly this
reason; idempotency: false beside serviceCallers is refused.
Credentials — one per edge
A service authenticates with a bearer credential per edge (caller → callee), the SAME value on both ends:
| On | Variable |
|---|---|
| the caller | VOLTRO_API_<BINDING>_TOKEN (and VOLTRO_API_<BINDING>_URL in production) |
| the callee | VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN (and …_TOKEN_NEXT during a rotation) |
voltro dev derives the value for every edge of a workspace and mints it into
both apps' gitignored .env.local — the two ends agree without writing into each
other's directory. In production nothing is minted: generate one per edge and
environment, and a missing or hand-chosen token fails boot:
voltro secret generate service --caller worker --api cloudRotate without downtime: set the new value as …_TOKEN_NEXT on the callee,
switch the caller, then move it to …_TOKEN and drop _NEXT.
Push: reacting to another app's open work
Voltro is reactive, and that is how one app should learn about another's work — not by polling its tables, and not by the owner calling the worker (which would make it depend on the worker). The worker watches a live query of the owner with a reaction:
// apps/cloud/worker/reactions/deployPending.reaction.ts
import { defineReaction } from '@voltro/runtime'
export default defineReaction({
name: 'deployPending',
watch: { api: 'cloud', query: 'deployments.pending' },
key: (row: { readonly deploymentId: string }) => row.deploymentId,
act: { kind: 'workflow', workflow: 'deploy.run' },
})Every delivery is the whole set of pending work, so nothing is lost across a restart, each row starts its workflow exactly once across every replica, and the owner never learns its watcher exists.
One database, one app
The first boot that migrates a database records which app owns it. A different
app booting against the same database and schema refuses before it plans
anything — under voltro dev, voltro serve and VOLTRO_AUTO_MIGRATE=0 alike —
naming both apps:
schema ownership: this database belongs to the app 'api', and 'worker' is a
different app. … Give this app its own database (DB_URL) or its own schema
(DB_SCHEMA); if it needs the other app's data, call it …voltro db apply refuses the same way, and claims an unclaimed database. If an
app was RENAMED, hand its schema over once with voltro db claim --yes.
voltro doctor reports, before anything boots: api apps of a project that
resolve the same database, a procedure naming a caller the app does not accept,
an accepted caller no procedure admits, and a binding whose callee does not
accept the caller. That last one is about an api app's apis: — a web app's
apis: is the browser binding, whose calls carry the user's session, so the
callee's serviceCallers is not asked about it.
The store knows only its own tables
Every store call — in a handler, a startup, a schedule, a workflow — fails with
UndeclaredTableAccess on a table the app does not declare (its own table()s
and its plugins' tables) and that is not a framework table (_voltro_*). A table
name is a string, and a string is how one app used to reach another app's table
past its guards, audit and tenant rules. store.raw(sql) is the documented
escape hatch and is not checked.
Sharing code between apps: building blocks
Code — not data — is shared with a plugin that brings its own primitives, a building block. Each app that installs it gets the block's procedures, workflows and schedules, and its own copy of the block's tables in its own database.
Testing
makeServiceApiMock from @voltro/testing stands in for ctx.apis.<name> in a
unit test. It is built over the callee's generated serviceDescriptors and
encodes every input and decodes every output through their schemas — the round
trip a real call makes — so a test cannot pass against a call the callee would
refuse:
import { makeServiceApiMock, makeTestContext } from '@voltro/testing'
import { serviceDescriptors, type ServiceProcedures } from '@acme/api/serviceGroup'
const cloud = makeServiceApiMock<ServiceProcedures>(serviceDescriptors, {
'deployments.activate': async () => ({ activated: true }),
})
const ctx = makeTestContext({ apis: { cloud: cloud.api } })cloud.calls('deployments.activate') lists what was called, and cloud.emit
pushes a new value to a subscribed query.
Both apps, end to end: startTestApps
startTestApps boots the apps as real voltro processes — dev by default,
serve with mode: 'serve' — and wires every edge you list the way production
does: a fresh credential per edge, written to both ends
(VOLTRO_API_<BINDING>_TOKEN / _URL on the caller,
VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN on the callee). Apps are keyed by their
name, each on a free port, and the call returns once every app answers
/internal/readiness. Separate processes on purpose: two apps with their own
tables cannot share one process's table registry, so one process would test a
topology no deployment has.
import { afterAll, beforeAll, expect, it } from 'vitest'
import { makeServiceClient } from '@voltro/client/runtime'
import { startTestApps, type StartedTestApps } from '@voltro/testing'
import { serviceDescriptors, serviceGroup } from '@acme/api/serviceGroup'
let apps: StartedTestApps<'api' | 'worker'>
beforeAll(async () => {
apps = await startTestApps({
apps: {
api: { root: 'apps/cloud/api', env: { DB_URL: process.env.API_TEST_DB_URL ?? '' } },
worker: { root: 'apps/cloud/worker', env: { DB_URL: process.env.WORKER_TEST_DB_URL ?? '' } },
},
edges: [{ caller: 'worker', callee: 'api', binding: 'cloud' }],
})
}, 300_000)
afterAll(() => apps.stop())
it('the worker drains the open deployments', async () => {
const api = makeServiceClient({
name: 'cloud',
wsUrl: apps.apps.api.wsUrl,
token: () => apps.token('worker', 'api'),
group: serviceGroup,
descriptors: serviceDescriptors,
})
expect(await api.call('deployments.pending', {})).toEqual([])
await api.close()
})apps.token(caller, callee) is the edge's credential — the test calls the
callee AS the caller, through the same contract module. An app that exits or is
not ready in time stops the others and rejects with its log.