Skip to main content

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:

SignatureModegRPCRESTGraphQLMCP
op(args): Promise<R>noneunaryas usualQuery (read-only) / Mutationtool
async *op(args): AsyncGenerator<T>serverserver stream, livelive NDJSON / SSESubscriptionprogress notifications
op(items: AsyncIterable<T>): Promise<R>clientclient streamnot exposednot exposed (see below)not exposed
async *op(items: AsyncIterable<T>): AsyncGenerator<U>bidibidi streamWebSocket on the operation URLnot 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 T as it is read; an invalid one fails the call (INVALID_ARGUMENT, WebSocket close 4400).
  • The request headers (gRPC metadata included) and the session are available through useContext(), also after a yield.
  • When the client goes away, the input ends and the generator is closed (finally blocks run) at its next write or drained() call, on every transport: a generator suspended on an external await stays suspended until it yields again. Output waits while the client cannot keep up.
  • grpc: { streaming } in @Operation still 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) with RESOURCE_EXHAUSTED; one that cannot be decoded with INVALID_ARGUMENT.
  • When the client cancels, the generator's return() is called, so its finally blocks run.
  • An operation that throws OperationCancelledError while the client is still connected ends the call with CANCELLED.

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-stream answers Content-Type: text/event-stream; charset=utf-8 with one data: <json> event per chunk; any other request answers Content-Type: application/x-ndjson with one JSON line per chunk. Both send Cache-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-alive comment is sent every streamKeepAliveInterval milliseconds (default 20000, 0 disables) while the response is open, and a normal end of the generator sends event: end with data: {} 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: error even if no chunk was sent). After the first chunk the status is already sent: SSE sends event: error with data: {"message": …, "code": <status>}, NDJSON a last line {"error": {"message": …, "code": <status>}}, then the response ends. A 4xx WebdaError keeps its message; anything else is reported as Internal server error and logged at ERROR. An OperationCancelledError raised 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 an error key 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 (finally blocks 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.Request event and the router request filters run first, then the operation permission; a refused upgrade answers HTTP 403 (400 when the request cannot be read).
  • A plain HTTP request to the URL answers 426 Upgrade Required.
  • Close codes: 1000 when the generator returns; 4000 + status for any WebdaError with 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 reason Internal server error; 1011 with that same generic reason for any other error, so internals do not leak; 4400 for a message that is not valid JSON or does not match T; 4413 when the client sends faster than the operation reads (see webSocketMaxQueuedMessages and webSocketMaxQueuedBytes).
  • Operations whose path has parameters (templated, e.g. /items/{id}/connect) are not served over WebSocket: a WARN is logged at startup.
  • Transport parameters:
ParameterDefaultMeaning
webSocketMaxPayload1048576 (1 MiB)Largest incoming message in bytes; a larger one closes the socket
webSocketMaxQueuedMessages1000Most received messages waiting to be read by the operation; above it the socket closes with 4413
webSocketMaxQueuedBytes16777216 (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, or mcp: { readOnly: true }) are Query fields, the others Mutation fields, server-streaming operations Subscription fields; 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: false hides it;
  • arguments are the input schema properties (uuid for an instance model action), the result a model type for a model output, Boolean for void, else the output schema;
  • errors are NOT_FOUND, PERMISSION_DENIED and BAD_USER_INPUT like the model fields; any other 4xx error keeps its message (OPERATION_REFUSED), everything else is logged and reported as Internal server error (INTERNAL_SERVER_ERROR) without its internal message;
  • leaving a subscription cancels the operation, so the generator's finally blocks run;
  • the exposeOperations parameter 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 HTTP GET).
  • "delegate": The operation method is executed and decides how to handle dry mode internally via useDryRun().
  • "skip": The method is not called. Input validation, permissions, and authorizers still run. Arguments have secrets masked, an INFO log DRY RUN <operationId> is emitted, event Webda.OperationDryRun is fired, and a DryRunResult is 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:

  1. 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 global enabled or request flags. exclude never overrides enforce.
    • allowRequest: true: Allows clients to request dry mode via the X-Webda-Dry-Run: true HTTP header.
  2. CLI: Global --dry-run flag:
    webda user delete <uuid> --dry-run
  3. 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:

  1. Permission (permission query), input validation and operation authorizers (registerOperationAuthorizer)
  2. Transaction begin: every registered transaction participant is begun
  3. Webda.BeforeOperation
  4. The method; a streamed result is fully written to the client before the next step
  5. Transaction commit, participants in registration order
  6. 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 runAsSystem or in a nested callOperation takes 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: false on an operation (@Operation({ transaction: false }) or registerOperation) 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 return 404 when gated by an inactive flag. The OpenAPI specification is static: a flagged operation stays in it, annotated with x-webda-feature-flag: <flag> (an array when several flags apply).
  • MCP: Disabled operations are omitted from listTools for 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_FOUND status.
  • 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 with NotFound once it is turned off.
  • Audit / Events: When a disabled operation is called, Webda.OperationDisabled is emitted ({ context, operationId, flag }) and operation_disabled_total is incremented. Neither Webda.BeforeOperation nor Webda.OperationFailure is 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.

See also​