Skip to content

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.

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.

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.

{
"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 source
groveback/functions/notify-on-order.json ← everything else, pointing at the .ts

Which 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.

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 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.

{ "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.
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.

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.

Terminal window
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.

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.

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.

Terminal window
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.

Terminal window
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.

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.

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.

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:

Terminal window
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.