Functions & hooks
A function is a per-project ESM module that runs in a sandboxed Bun Worker. It fires on an event, on a schedule, or serves a custom HTTP endpoint.
Triggers
Section titled “Triggers”| Trigger | Shape |
|---|---|
database |
{ type: "database", collection, events: ["insert","update","delete"] } — an update subscription also fires on replace. |
auth |
{ type: "auth", event: "userCreated" } |
storage |
{ type: "storage", event: "fileUploaded" } |
http |
{ type: "http", method, path } — :params allowed, at most 8 segments. |
cron |
{ type: "cron", schedule } — needs Redis. |
Timing: pre versus post
Section titled “Timing: pre versus post”post (the default) runs asynchronously after the write, queued. Side effects: email,
webhooks, aggregates.
pre runs synchronously inside the write and can abort or mutate it. Only valid for
database triggers — auth and storage events fire after the fact, so there is nothing to
abort.
export default async (event, ctx) => { if (!event.document.email?.includes('@')) { return { abort: true, reason: 'email is invalid' }; } return { document: { ...event.document, normalizedEmail: event.document.email.toLowerCase() } };};| Return | Effect |
|---|---|
| nothing | Allow, unchanged. |
{ abort: true, reason } |
Reject the write. Surfaces as HTTP 400 VALIDATION with the reason. |
{ document } |
Replace the document for the next hook and the write. Ignored for deletes. |
Pre-hooks are fail-closed: an error, a timeout, or a busy worker pool rejects the write. A mutated document is re-validated afterwards — schema, field-level authorization and CEL all run again, and system fields are re-pinned. A hook can shape data but never forge identity.
Definition
Section titled “Definition”{ "name": "notify-on-order", "trigger": { "type": "database", "collection": "orders", "events": ["insert"] }, "timing": "post", "enabled": true, "timeoutMs": 15000, "code": "export default async function (event, ctx) { … }"}Names match ^[a-z][a-z0-9-]{1,63}$. Code is syntax-checked at save time and capped at 256 KB.
In the bundle, the handler is a real file:
groveback/functions/notify-on-order.ts ← the handler sourcegroveback/functions/notify-on-order.json ← everything else, pointing at the .tsWhich is the point: a handler is something you edit in your editor and test with
@groveback/testkit, not a string field in a JSON blob.
The context
Section titled “The context”export default async function (event, ctx) { … }ctx |
What it is | Available on |
|---|---|---|
ctx.db |
A service-role database handle that runs below the policy engine. | post, http, manual — never pre |
ctx.integrations |
call(name, op, args) for configured integrations. |
post, http, manual — never pre |
ctx.auth |
The caller’s identity, or null. |
http (and wherever a caller exists) |
ctx.request |
{ method, path, params, query, headers, body }. |
http |
ctx.project, ctx.function |
Ids for logging. | everywhere |
ctx.db exposes find, findOne, count, insertOne, updateMany, deleteMany.
The sandbox
Section titled “The sandbox”The worker has no database client and no secrets, and strips its own environment at
startup, preserving only PATH. Everything it can reach goes through a host-mediated RPC
bridge, across a structuredClone boundary — so Dates and Sets survive, functions do not.
This is why ctx.db is a bridge rather than a client: the host does the I/O.
Custom HTTP endpoints
Section titled “Custom HTTP endpoints”{ "name": "checkout", "trigger": { "type": "http", "method": "POST", "path": "/checkout/:id" } }Served at /api/v1/run/<project>/checkout/<id>, where <project> is the id or the slug —
so the URL is self-contained and needs no header.
export default async function (event, ctx) { if (!ctx.auth) return { status: 401, body: { error: 'sign in first' } }; const order = await ctx.db.findOne('orders', { id: ctx.request.params.id }); return { status: 200, body: { order } };}The return shape is { status?, headers?, body? }, defaulting to 200 and a null body. Any
other return value becomes a 200 with that value as the body.
Route matching is segment-wise: :param captures and is percent-decoded, and static
segments beat params at equal length, so /posts/new wins over /posts/:id. Two routes with
the same method and the same shape-with-params conflict and are rejected at save time.
Status mapping:
| Situation | Status |
|---|---|
| Handler error | 500 { error: "function error" } — the message is never echoed to callers, but it is in the run log. |
| Timeout | 504 { error: "function timed out" } |
| Worker pool saturated | 503 — retryable. |
Timeouts
Section titled “Timeouts”| Setting | Default | Notes |
|---|---|---|
| Per run | 10 s | Override per function with timeoutMs (100 ms – 60 s). |
| Per pre-hook | 2 s | A per-function timeoutMs on a pre-hook is clamped to this — asking for less works, asking for more does not. |
| Whole pre-hook chain | 5 s | Across all pre-hooks on one write. |
Pre-hooks are tight on purpose: they run inside somebody’s write.
Versions and rollback
Section titled “Versions and rollback”Every change to code, trigger, timing or timeoutMs cuts a version — but toggling enabled
or editing tests does not. Caps: 50 versions and 50 run-log entries per function.
curl -X POST "$URL/api/v1/admin/functions/notify-on-order/rollback" \ -H "authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \ -d '{"version":3}'Rollback is non-destructive: it re-applies a snapshot as a new version, so the history stays intact.
Visual flows
Section titled “Visual flows”A function can be defined as a flow (nodes and edges) instead of code. The code is then
compiled output, and direct code edits are rejected — the bundle’s .ts file carries a header
saying so. To eject to hand-written code, PATCH { flow: null, code }. Flows are post-only.
Stored tests
Section titled “Stored tests”Up to 20 cases per function, stored on the definition. Each runs in the real runner against a fresh scratch database, with integration calls answered from JSON stubs.
curl -X POST "$URL/api/v1/admin/functions/notify-on-order/tests/run" \ -H "authorization: Bearer $ADMIN_KEY"Stub lookup order is "name.op" → "name.*" → "*", and { "$error": "message" } simulates
a failure. Stored tests never touch your project’s data, never append run logs, and never
count against plan limits.
For tests in your own runner with coverage, use the testkit.
Logs and stats
Section titled “Logs and stats”curl "$URL/api/v1/admin/functions/notify-on-order/logs?limit=20" -H "authorization: Bearer $ADMIN_KEY"curl "$URL/api/v1/admin/functions/stats" -H "authorization: Bearer $ADMIN_KEY"console.log/info/warn/error/debug is captured as run logs, capped at 200 lines, with
non-strings JSON-stringified. A timeout produces status: 'timeout' with empty logs — the
killer takes the worker before it can report.
Plan limits
Section titled “Plan limits”On hosted deployments each executed run increments a monthly counter. Over budget, HTTP and manual invocations return 429 before running, and queued background runs are skipped with a run-log entry.
Pre-hooks are exempt — never counted, never blocked. A quota must not be able to break writes.
Integrations versus webhooks
Section titled “Integrations versus webhooks”Reaching outward has two shapes, and picking the wrong one costs you a rewrite:
- Integration — your code calls out:
await ctx.integrations.call('notifier', 'send', {…}). Credentials are stored encrypted and never reach handler code. See below. - Webhook — declarative, no code: a collection plus events, and Groveback POSTs a signed payload to your URL.
Integrations in brief
Section titled “Integrations in brief”Providers today are resend (email), telegram (bots) and http (generic APIs).
{ "name": "notifier", "provider": "resend", "config": { "from": "noreply@acme.com" }, "enabled": true }name is the handle you use from code and follows ^[a-z][a-z0-9-]{1,63}$. provider is
immutable after create. Secrets are supplied separately, encrypted at rest, and never
returned — reads redact to { configured: boolean }, and an update that omits a secret field
preserves the stored value.
Each provider declares an allowlist of ops, validated host-side before any outbound call. Calls are capped at 10 seconds and abort when the worker job settles.
The http provider is origin-locked and appends: a request path is relative and appends
under the stored baseUrl, so a webhook URL with its own path survives intact. For it,
non-2xx is data, not an error — you get { status, ok, body } and decide what it means.
Branded providers throw with the API’s own message.
Test one without sending anything:
curl -X POST "$URL/api/v1/admin/integrations/notifier/test" \ -H "authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \ -d '{"op":"send","args":{"to":"a@example.com"},"mode":"dry"}'mode: "dry" runs the entire path except the outbound call. mode: "live" really sends,
and a provider or network failure maps to 502 UPSTREAM_FAILED rather than an opaque 500.