Operations
Defined by the annotation @Operation it has:
- Id
- Parameters
- Output
This define what is supposed to be accessible in this application as an operation.
The Operation can be call through several type:
- REST API
- Async Action API
- Slack bot
- WebSockets event
OperationCall is
- Uuid
- Operation
- Parameters
- Output
A @Route can be seen as an Operation
REST input binding
Method parameters define the input schema; the return type defines the output (Promise<T> is described as T).
An operation receives its arguments in declaration order, including undefined for omitted optional parameters so
JavaScript defaults apply. Use useContext() or a parameter typed as OperationContext to access the caller.
@Operation({ rest: { method: "get", path: "projects/{projectId}/tasks{?status,limit?,offset?}" } })
async listTasks(projectId: string, status: string, limit = 20, offset?: number): Promise<Task[]> {
// ...
}
The URI template determines where each parameter comes from:
{name}binds a required path parameter.{?a,b}binds required query parameters;{?a?,b?}makes them optional.{?filters*}collects the remaining query keys into an object parameter. Its values must be scalars.- POST, PUT and PATCH read the other parameters from a JSON object body.
- GET and DELETE automatically add the other scalar parameters to the query template, preserving their required or optional status. Objects and arrays need a body; scalar arrays can be explicitly bound in the query template.
Only URL values are converted before validation: numbers must be finite, booleans accept true, false, 1 and
0, and scalar arrays accept repeated keys (?tag=a&tag=b) or comma-separated values. Catch-all values are converted
according to their property schemas. JSON body values remain strict: "20" is invalid for a number. A body cannot
replace a conflicting URL parameter.
For a single object or array that should occupy the whole JSON body, name its parameter explicitly:
@Operation({ rest: { method: "post", path: "projects/{projectId}/tasks", body: "task" } })
async createTask(projectId: string, task: TaskInput): Promise<Task> {
// POST /projects/p1/tasks with { "title": "Review" } passes that object as task
}
Without rest.body, that example requires { "task": { "title": "Review" } }. The whole-body parameter must be the
only parameter left after path and query binding.
webdac build rejects template names that do not match method parameters and unsupported object/array URL bindings.
The input schema records the binding as x-webda-rest in webda.module.json. OpenAPI uses the same mapping for typed
parameters and requestBody; GET/DELETE have no request body. Other transports continue passing named input fields.
When migrating a hand-written REST client, move GET/DELETE arguments from JSON bodies into the query string.
Streaming operations
An operation streams when its signature says so: an async * method (or one returning AsyncGenerator<T> /
AsyncIterable<T>) produces a stream of T, and a single parameter typed AsyncIterable<T> consumes one. The
compiler describes each side by T and marks it x-webda-stream; the mode follows:
| Signature | Mode | gRPC | REST | GraphQL | MCP |
|---|---|---|---|---|---|
op(args): Promise<R> | none | unary | as usual | Query (read-only) / Mutation | tool |
async *op(args): AsyncGenerator<T> | server | server stream, live | live NDJSON / SSE | Subscription | progress notifications |
op(items: AsyncIterable<T>): Promise<R> | client | client stream | not exposed | not exposed (see below) | not exposed |
async *op(items: AsyncIterable<T>): AsyncGenerator<U> | bidi | bidi stream | WebSocket on the operation URL | not exposed (see below) | skipped |
@Operation()
async *connect(frames: AsyncIterable<Envelope>): AsyncGenerator<Envelope> {
const auth = useContext<WebContext>().getHttpContext()?.getUniqueHeader("authorization");
yield { frame: "welcome" };
for await (const envelope of frames) yield handle(envelope, auth);
}
- Each incoming message is validated against
Tas it is read; an invalid one fails the call (INVALID_ARGUMENT, WebSocket close4400). - The request headers (gRPC metadata included) and the session are available through
useContext(), also after ayield. - When the client goes away, the input ends and the generator is closed (
finallyblocks run) at its next write ordrained()call, on every transport: a generator suspended on an externalawaitstays suspended until it yields again. Output waits while the client cannot keep up. grpc: { streaming }in@Operationstill overrides the mode.
gRPC
- Messages are the JSON-equivalent objects of the operation; a chunk that is not an object is sent as
{ value }, and__-prefixed keys never leave the server. - A compressed message ends the call with
UNIMPLEMENTED; a message over 4 MiB (GRPC_MAX_MESSAGE_SIZE) withRESOURCE_EXHAUSTED; one that cannot be decoded withINVALID_ARGUMENT. - When the client cancels, the generator's
return()is called, so itsfinallyblocks run. - An operation that throws
OperationCancelledErrorwhile the client is still connected ends the call withCANCELLED.
REST: NDJSON and server-sent events
A server-streaming operation is streamed live on its REST route by Webda/RESTOperationsTransport, each chunk sent
as soon as the operation yields it:
Accept: text/event-streamanswersContent-Type: text/event-stream; charset=utf-8with onedata: <json>event per chunk; any other request answersContent-Type: application/x-ndjsonwith one JSON line per chunk. Both sendCache-Control: no-cache.- A chunk is its JSON value without
__-prefixed keys; a chunk that is not an object is sent as is ("text",42), not wrapped. - SSE only: a
: keep-alivecomment is sent everystreamKeepAliveIntervalmilliseconds (default20000,0disables) while the response is open, and a normal end of the generator sendsevent: endwithdata: {}before closing, so a client can tell it from a dropped connection. NDJSON just closes. - Permissions, input validation and events are those of a normal call. An error raised before the first chunk is the
usual HTTP error response (for SSE, until the first keep-alive: once one is sent the response head is committed, so
a later error is an
event: erroreven if no chunk was sent). After the first chunk the status is already sent: SSE sendsevent: errorwithdata: {"message": …, "code": <status>}, NDJSON a last line{"error": {"message": …, "code": <status>}}, then the response ends. A 4xxWebdaErrorkeeps its message; anything else is reported asInternal server errorand logged atERROR. AnOperationCancelledErrorraised by the operation while the client is connected is such an error. The last{"error": …}line is the stream's error marker: an operation that yields anerrorkey at the top level of a chunk is indistinguishable from it. - Changes made to the session after the first chunk was sent do not reach a cookie session (the cookie leaves with the headers).
- When the client disconnects the generator is closed (
finallyblocks run, see above) and nothing more is written. Output waits while the client cannot keep up. A non-generator operation on such a route answers a normal JSON response, without keep-alive. - The OpenAPI document lists both content types for these routes.
WebSocket (REST transport)
A bidirectional operation is served as a WebSocket on its REST route by Webda/RESTOperationsTransport; the upgrade
listener is only attached when at least one such route exists.
- One JSON text message per item each way.
- The upgrade is checked like a normal request: the
Webda.Requestevent and the router request filters run first, then the operation permission; a refused upgrade answers HTTP403(400when the request cannot be read). - A plain HTTP request to the URL answers
426 Upgrade Required. - Close codes:
1000when the generator returns;4000 + statusfor anyWebdaErrorwith a response code (e.g.4404); only a 4xx keeps its message as the reason (truncated to 123 bytes of UTF-8), a 5xx closes with the generic reasonInternal server error;1011with that same generic reason for any other error, so internals do not leak;4400for a message that is not valid JSON or does not matchT;4413when the client sends faster than the operation reads (seewebSocketMaxQueuedMessagesandwebSocketMaxQueuedBytes). - Operations whose path has parameters (templated, e.g.
/items/{id}/connect) are not served over WebSocket: aWARNis logged at startup. - Transport parameters:
| Parameter | Default | Meaning |
|---|---|---|
webSocketMaxPayload | 1048576 (1 MiB) | Largest incoming message in bytes; a larger one closes the socket |
webSocketMaxQueuedMessages | 1000 | Most received messages waiting to be read by the operation; above it the socket closes with 4413 |
webSocketMaxQueuedBytes | 16777216 (16 MiB) | Most bytes of received messages waiting to be read by the operation; above it the socket closes with 4413 |
GraphQL
GraphQLService exposes every operation next to the model schema, except the model CRUD operations the model
schema already serves (X, Xs, createX, updateX, deleteX):
- read-only operations (REST
GET, ormcp: { readOnly: true }) areQueryfields, the othersMutationfields, server-streaming operationsSubscriptionfields; client and bidirectional streams are not exposed: standard GraphQL has no client-to-server stream (a subscription only streams server to client, and a mutation takes its arguments once), so use gRPC or the WebSocket route for them; - the field name is the operation id in lower camel case (
TaskService.Summary→taskServiceSummary);@Operation({ graphql: { query | mutation | subscription: "name" } })renames it,graphql: falsehides it; - arguments are the input schema properties (
uuidfor an instance model action), the result a model type for a model output,Booleanfor void, else the output schema; - errors are
NOT_FOUND,PERMISSION_DENIEDandBAD_USER_INPUTlike the model fields; any other 4xx error keeps its message (OPERATION_REFUSED), everything else is logged and reported asInternal server error(INTERNAL_SERVER_ERROR) without its internal message; - leaving a subscription cancels the operation, so the generator's
finallyblocks run; - the
exposeOperationsparameter filters them with the transports' patterns (["*", "!User.Delete"]); every operation is exposed by default,exposeOperations: []exposes none (the opt-out), and a list of exclusions only (["!User.Delete"]) means "all but these".
query {
taskServiceSummary(project: "p1") {
open
done
}
}
mutation {
userFollow(uuid: "u1")
}
subscription {
sessionEvents(session: "s1") {
seq
type
}
}
Dry mode
Dry mode allows operations to be validated, checked against permissions, and reported without performing unintended side effects.
Operation dry-run behaviors
Each operation defines its behavior in dry mode via the dryRun option:
"execute": The operation has no side effects and executes normally. This is the default for read-only operations (Get,Query, or operations exposed with HTTPGET)."delegate": The operation method is executed and decides how to handle dry mode internally viauseDryRun()."skip": The method is not called. Input validation, permissions, and authorizers still run. Arguments have secrets masked, an INFO logDRY RUN <operationId>is emitted, eventWebda.OperationDryRunis fired, and aDryRunResultis written to the context. This is the default for mutating operations.
Example of a "delegate" operation:
@Operation({ dryRun: "delegate" })
async migrate(): Promise<MigrationReport> {
const dry = useDryRun();
// ...compute the migration plan...
if (!dry) {
await this.apply(plan);
}
return report;
}
Enabling dry mode
Dry mode can be activated via:
- Configuration (
parameters.dryRun):enabled: true: Runs every operation in dry mode.enforce: ["Billing.*", "User.Delete"]: Patterns of operations that always run dry.exclude: ["Health.*"]: Patterns of operations excluded from globalenabledor request flags.excludenever overridesenforce.allowRequest: true: Allows clients to request dry mode via theX-Webda-Dry-Run: trueHTTP header.
- CLI: Global
--dry-runflag:webda user delete <uuid> --dry-run - Programmatically: Using the
runDryRun(fn)helper in tests or scripts.
Operation lifecycle and transactions
callOperation runs every operation through the same steps, whatever the transport:
- Permission (
permissionquery), input validation and operation authorizers (registerOperationAuthorizer) - Transaction begin: every registered transaction participant is begun
Webda.BeforeOperation- The method; a streamed result is fully written to the client before the next step
- Transaction commit, participants in registration order
Webda.OperationSuccess: audit and IAM listeners only ever see committed data
On any failure (a check, begin, the method, the stream or a commit), the participants not committed yet are
rolled back and then Webda.OperationFailure is emitted; the original error is rethrown. An error thrown by a
rollback is logged and never hides it.
A transaction participant implements OperationTransactionParticipant (begin, commit, rollback) and is
registered with registerOperationTransactionParticipant. Without registered participants nothing changes. The SQL
stores (PostgresStore, SQLiteStore) are participants when configured with transaction: "operation", see
PostgreSQL transactions.
- The transaction follows the async call chain of the operation, not the execution context: code running inside the
operation under
runAsSystemor in a nestedcallOperationtakes part in it.useOperationTransaction()returns it. - Nested operations join the transaction of the outermost operation: only the outermost one begins and finishes it (no savepoints).
transaction: falseon an operation (@Operation({ transaction: false })orregisterOperation) asks the participants to stay out, e.g. for a long streaming read that should not hold a database connection.runWithTransaction(fn)runs code outside an operation (CLI, queue workers, tests) in a transaction of the registered participants, or joins the current one.- Participants commit one after the other: when the commit of a second participant fails after the first one committed, the first one stays committed (no two-phase commit).
- Repository events (
Created,Updated...) are emitted when the statement runs, before the commit: a listener may see data that is later rolled back.
Feature flags
Operations can be gated by a named feature flag. When a flag evaluates to off for the caller, the operation behaves as if it did not exist: transports omit it from listings (canCallOperation() returns false) and direct invocations fail with 404 NotFound ("<id> Unknown"), hiding validation schemas and permissions.
Declaring a flag on an operation
In code, use @Operation({ featureFlag: "flag-name" }) or @Action({ featureFlag: "flag-name" }). Setting featureFlag: true automatically uses the operation's id as the flag name:
@Operation({ featureFlag: "new-checkout" })
async checkout(cart: Cart): Promise<Order> { ... }
@Operation({ featureFlag: true }) // flag name = "Orders.Export"
async export(): Promise<Url> { ... }
Model @Actions support featureFlag as well, propagated by the compiler into operation metadata:
@Action({ featureFlag: "bulk-archive" })
async bulkArchive() { ... }
Configuration mapping
Flags can also be attached to operations via configuration without modifying code. This is particularly useful for DomainService CRUD operations (Posts.Create, Users.Delete, etc.):
{
"services": {
"featureFlags": {
"type": "Webda/FeatureFlags",
"flags": {
"safe-delete": false
},
"operations": {
"Posts.Delete": "safe-delete",
"Admin.*": "admin-v2"
}
}
}
}
If an operation has both a code flag and a configuration mapping, all flags must evaluate to true for the operation to be enabled.
NotFound semantics and transport gating
- Fail closed: By default, if no provider recognizes a flag, the flag is off.
- REST: Routes remain registered, but invocations return
404 NotFound. WebSocket upgrade requests return404when gated by an inactive flag. The OpenAPI specification is static: a flagged operation stays in it, annotated withx-webda-feature-flag: <flag>(an array when several flags apply). - MCP: Disabled operations are omitted from
listToolsfor callers who do not satisfy the flag. - GraphQL: Disabled operations fail subscriptions with
NOT_FOUND; the schema stays static. - gRPC: a call to a disabled operation ends with the
NOT_FOUNDstatus. - Async jobs: a disabled operation cannot be scheduled (
NotFound) and is left out of the listing. The flag is evaluated again when the job runs, so a job queued while the flag was on fails withNotFoundonce it is turned off. - Audit / Events: When a disabled operation is called,
Webda.OperationDisabledis emitted ({ context, operationId, flag }) andoperation_disabled_totalis incremented. NeitherWebda.BeforeOperationnorWebda.OperationFailureis emitted.
The flags themselves are evaluated by the registered FeatureFlagProviders: see Feature flags for the configuration-backed Webda/FeatureFlags service and how to plug an external provider.
Programmatic flag evaluation
Business logic can check feature flags directly using isFeatureEnabled():
import { isFeatureEnabled } from "@webda/core";
if (await isFeatureEnabled("new-pricing")) {
// use new calculation
}
Calling operations from a frontend
@webda/sdk generates a typed client from the operations of the application: every
non-hidden operation is an entry of the generated Operations interface, callable with api.call("Task.Get", { uuid }),
consumed with api.stream("Audit.Tail", { since }) when it streams, and the operations of a model are wrapped by a
model client (api.Task.get(uuid), api.Task.query("status = 'open'"), api.Task.archive(uuid, { reason })) backed
by a normalized cache. The REST routes of the operations are read from the router, so what the transport exposes is
exactly what the SDK calls.