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:full user 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 execute

Audit 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 ApiUnavailable when 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_seconds and voltro_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 buildExecute

An 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 cloud

Rotate 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.