gRPC surface

Serve opt-in procedures to generated gRPC clients — proto emitted from your effect/Schema with checked-in field-number stability, unary for mutations/actions, server-streaming for live queries, guards + interceptors + typed errors identical to the socket.

The gRPC surface serves a NAMED list of your procedures to external gRPC clients — the polyglot-microservice door. The .proto is generated from the same effect/Schema your procedures already declare, so there is no second contract to maintain; the wire semantics are the framework's own: guards, plugin interceptors and typed errors behave identically to the rpc socket, because a gRPC call runs the same bound runner every other surface uses (the e2e proves interceptor order side by side).

// app.config.ts
export default {
  type: 'api' as const,
  name: 'api',
  grpc: {
    port: 50051,
    procedures: ['orders.get', 'orders.list', 'orders.create'],
    // tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
    // drainMs: 5000,        // shutdown drain budget — see below
    // maxMessageBytes, maxMetadataBytes — grpc-js frame limits
  },
}

NOTHING is exposed by default — every tag is named. Booting writes .framework/grpc.proto (hand it to any proto codegen) and mounts grpc.health.v1 health checking plus server reflection (grpcurl … list works out of the box). The gRPC packages ship as script-free optional dependencies of @voltro/cli; a configured grpc: block with them missing refuses the boot by name.

Shutdown drains, then forces — drainMs

On SIGTERM the surface flips its health status to NOT_SERVING (so a load balancer stops sending it work) and gives open calls drainMs to finish before force-closing them. Default 5000; 0 forces immediately; the env override is VOLTRO_GRPC_DRAIN_MS.

Pick it from two numbers only you have. Keep it below your orchestrator's termination grace (terminationGracePeriodSeconds, docker stop -t) — past that point SIGKILL arrives and the drain never completes, so a larger budget buys nothing. Keep it above your longest legitimately in-flight unary call, or every rolling deploy force-closes work that would have finished. When the budget is exceeded the surface says so in a warning naming the budget, rather than leaking the port into the next boot.

Field numbers are managed — grpc.manifest.json

Field numbers are the proto wire identity, so they may never depend on property order. They come from a checked-in manifest in your app root:

  • a new field gets the next never-used number — an inserted field never renumbers its neighbours;
  • a deleted field's number becomes reserved (emitted into the proto, so protoc refuses a colliding hand-edit too);
  • reusing a reserved number is a codegen error, never a warning — an old client would silently read the wrong field.

Commit the manifest with the schema change that moved it: the diff review IS the wire-contract review.

The mapping table

Schema proto3
Schema.String / Number / Boolean string / double / bool
integer schemas int64
Schema.Array(T) repeated T
nested Schema.Struct nested message
Schema.Record({ key: String, value: T }) map<string, T>
Schema.optional(T) and Schema.NullOr(T) optional T — absent and null are ONE wire state (proto3 presence)
string-literal unions string (validated server-side on decode)
unions of shapes, tuples, recursion, free-form objects a LOUD per-procedure codegen error naming the schema path

Requests are decoded against the descriptor's input schema before the executor runs — proto3 suppresses default values on the wire, and without that decode an empty string would arrive as an absent field and fail somewhere much later.

Status codes — complete against the wire error union

outcome gRPC status trailers
no credential on a guarded call UNAUTHENTICATED
presented-and-rejected credential UNAUTHENTICATED
authenticated, missing scope (ScopeError) PERMISSION_DENIED voltro-error: scope
input fails the schema INVALID_ARGUMENT voltro-error: input
BusinessRuleViolation FAILED_PRECONDITION voltro-error: rule
requiresApproval pending — a FLOW OUTCOME, not a failure FAILED_PRECONDITION voltro-pending: approval + voltro-approval-id
your declared typed error FAILED_PRECONDITION voltro-error: <tag>
deadline exceeded DEADLINE_EXCEEDED
anything else INTERNAL

Deadlines interrupt the work. A client deadline (grpc-timeout) aborts the executor's fiber through the request signal — the server stops doing the work, it does not merely suppress the response (the e2e pins this with a sleeping action whose post-sleep write never lands).

Streaming queries

A query becomes a server-streaming rpc: each frame is the CURRENT full snapshot, re-pushed live when the query's source: changes — subscribe, mutate from anywhere, and the open stream receives the new frame with no re-request.

Authorization is re-derived per FRAME, not frozen at open. Before every delivery the framework re-runs the query's guards: and re-resolves the subject's row-level visibility from the unfiltered base descriptor. A revoked scope ends the stream with the mapped status; a membership that ends mid-stream stops carrying those rows in the next frame, with the stream itself untouched. This is the same code the WebSocket and SSE transports run — an open gRPC stream is not a cheaper read path than a fresh call.

Slow consumers are handled through grpc-js write backpressure — frames coalesce to the latest snapshot rather than buffering unboundedly.

Declared limits (v1)

  • No client- or bidi-streaming, and *.stream.ts procedures are NOT exposable — the fourth kind is a one-shot element stream with its own semantics; put it behind a query or keep it on the socket.
  • No gRPC-Web — a browser talks the framework's own subscription protocol (that is the better browser transport in every dimension we care about); gRPC is for backends.
  • No Connect protocol — connectrpc is NOT gRPC-Web; a connect consumer's alternative today is the REST/OpenAPI projection.
  • App realtime stays on the framework's subscription protocol, and gateways exist for the other case: a FOREIGN protocol that needs a socket the framework does not speak — the same boundary data/subscriptions draws for raw WebSocket gateways, one sentence, two doors.