Create App backend functions
A guarded synchronous App backend function is the App-runtime equivalent of a
Windmill Script: one named, author-fixed SQL operation over the App’s bound
database, invoked over the App’s per-deployment callback as a typed
request/response. It runs synchronously under the same database guard the
workflow db_query / db_execute nodes use — there is no runs row and no
polling. Reserve workflows for real orchestration (multi-step, external calls,
durable, retries, human gates); reach for a backend function for a read or a
simple write.
How it works
Section titled “How it works”The function is declared in the App’s manifest_yaml and parsed into
BackendFunctionDecl (server/crates/sc-svc-projects/src/apps/manifest.rs).
Two endpoints surface it
(server/crates/sc-iface-http/src/interfaces/http/apps_backend_routes.rs):
| Endpoint | Purpose |
|---|---|
GET /api/apps/{id}/backend |
The function catalog — name, operation, and argument metadata. Never the SQL. |
POST /api/apps/{id}/backend/{function_name} |
Invoke — body { "args": [...] }, positional, bound to $1..$n. |
Both are authenticated by either a per-deployment App token (Bearer scwa_…)
or a logged-in session — resolved by resolve_submit_auth
(apps_runs_auth.rs), the same path the run-submit endpoint uses. A deployed
App’s server-side code calls them with its scwa_ token; the operator browses
them from the App detail Execute tab
(web/src/lib/components/apps/AppBackendTab.svelte).
-
Bind an
app_db:resource.A backend function needs a database to run against, so declaring any function requires the App to bind a
postgresqlresource viaapp_db:in the manifest.app_db:accepts the resource name (the ergonomic form) or its UUID —services::apps::resolve_app_db_refresolves it. On save the App’s per-Appapp_<id>schema is ensured and the RLS provisioner is run (ADR 0023 v4).Instead of a resource you own,
app_db:also accepts the reserved tokenbuiltin— the server-managed built-in App database. The first App that uses it lazily creates onebuiltin_app_dbresource for the workspace, shared by every App (each App is isolated in its own schema and least-privilege role under RLS). It is derived from platform credentials and owned by the server, so it appears in Connections/Resources as read-only (a System badge; Test, Edit, Delete and RLS-reprovision are disabled) — you never configure or test it yourself. -
Declare the function in
backend_functions:.Add one entry per function.
operationisquery(READ-ONLYSELECT/WITH) orexecute(INSERT/UPDATE/DELETE/MERGE, no DDL).sqlis the author-fixed, parameterised statement with$1..$nplaceholders.paramsis descriptive metadata for the calling UI (a label + an optionaltext | number | booleanhint); when present, its length is the expected positional argument count.app_db: workspace_app_dbbackend_functions:- name: overviewoperation: querysql: "SELECT id, total FROM ledger WHERE region = $1"params:- name: regionkind: text- name: mark_paidoperation: executesql: "UPDATE ledger SET paid = true WHERE id = $1"returning: falseFor an
executefunction, setreturning: trueto return the DML’sRETURNINGrows to the caller; it is ignored for aquery. -
Save the App.
On save,
validate::validate_backend_functions(server/crates/sc-svc-projects/src/apps/validate.rs) enforces, in order: everynameis non-empty and unique within the App; everysqlpasses the matching allowlist for itsoperation; and — if any function is declared — the App must bind anapp_db:resource. A malformed or dangerous function can therefore never be persisted. -
List the catalog.
Terminal window curl -s https://app.example.com/api/apps/$APP_ID/backend \-H "Authorization: Bearer $SCWA_TOKEN"Returns the declared functions as
BackendFnInfo—name,operation, andparams— with the SQL withheld. -
Invoke a function.
Pass the positional arguments in
args, in declaration order:Terminal window curl -s -X POST https://app.example.com/api/apps/$APP_ID/backend/overview \-H "Authorization: Bearer $SCWA_TOKEN" \-H "Content-Type: application/json" \-d '{ "args": ["EU"] }'The response is a
BackendFnResponse.operationtells the caller how to read it: aqueryreturnsrowswithrow_count = rows.len(); anexecutereturns the affected-row count inrow_countand anyRETURNINGrows inrows.{ "function": "overview", "operation": "query","rows": [{ "id": 1, "total": 940 }], "row_count": 1 } -
Call it from the App frontend.
A deployed App calls the same endpoints with its per-deployment
scwa_token (mint / rotate it owner-only atPOST /api/apps/{id}/deployment/token). The token is used server-side by the App’s backend — never embedded in the public frontend bundle — so the App’s own server invokesoverview()and returns the rows to its UI. From the operator side you can run any function ad-hoc on the App detail Execute tab, which callsappsApi.listBackendFnsandappsApi.invokeBackendFnagainst the same two endpoints.
The guard and safety model
Section titled “The guard and safety model”The guard is decoupled from its packaging — one decision point, enforced identically whether the SQL is packaged as a workflow node or invoked as a synchronous function. It is layered:
- Statement allowlist (
services::workflows::security::sql_guard, sqlparser): aquerymust parse to a singleSELECT/WITHand contain no write/DDL tokens; anexecutemust parse to a singleINSERT/UPDATE/DELETE/MERGEwith no DDL tokens. Enforced at save time and re-confirmed at call time (defense-in-depth against manifest drift). - Positional binding only. Arguments bind to
$1..$n; the SQL itself is fixed in the manifest. There is no string interpolation, so the wire carries no injectable SQL. - READ-ONLY runtime backstop. A
queryruns in aBEGIN READ ONLYtransaction withdefault_transaction_read_only = on(db_guarded_exec::exec_guarded_query), so even a mis-declaredquerywhose SQL is actually DML is rejected by Postgres at the transaction level. - RLS
supacloud.app_idbackstop. Before the statement runs, the kernel injectsSET LOCAL supacloud.app_idfrom the App identity, never the request, so the App’s RLS policies filter rows by App and a caller cannot spoof which App’s data it sees. RLS /app_dbis the always-on backstop regardless of access path. - Bounded blast radius. A per-call statement timeout (
10000ms) and a hard row cap (1000rows) keep a synchronous read sub-second and prevent an unbounded payload streaming back over the callback.
See also
Section titled “See also”server/crates/sc-svc-projects/src/apps/manifest.rs—BackendFunctionDeclshapeserver/crates/sc-svc-projects/src/apps/validate.rs—validate_backend_functionsserver/crates/sc-svc-projects/src/app_deployments/backend_fn.rs— the executorserver/crates/sc-svc-engine/src/workflows/db_guarded_exec.rs— the shared guarded kernelweb/src/lib/components/apps/AppBackendTab.svelte— the App Execute tab- ADR 0053 — guarded synchronous backend functions
- Add a database migration