15. Edge Functions
Alex needs to process a payment webhook and update an order — this requires custom server-side logic beyond SQL.
Edge Functions are serverless TypeScript/JavaScript functions that run on Eurobase's EU-sovereign infrastructure. They let you write custom server-side logic — payment processing, external API integrations, data transformations — without managing any servers.
EU Sovereign: Edge Functions run on Scaleway infrastructure in France. Unlike other platforms that route through US-hosted runtimes, your code and secrets never leave the EU.
Server-side logic in Eurobase: four kinds, when to pick which
Eurobase has four distinct surfaces for "code that runs on the server." The SDK shapes look similar in places, so it's worth getting the distinction clear before you start building. RPC functions and DB triggers are both PostgreSQL functions but with different invocation models; Cron jobs are scheduled wrappers around either an SQL statement or an RPC; Edge functions are a separate Deno runtime entirely.
| Cron Job | RPC Function | DB Trigger | Edge Function | |
|---|---|---|---|---|
| What it is | A schedule that runs SQL or an RPC at a cron expression | A reusable Postgres function (SQL/PL-pgSQL) you call by name | A Postgres function bound to a row event on a table | Serverless TS/JS in a Deno container |
| Where it runs | Inside Postgres (cron worker fires the SQL/RPC) | Inside Postgres | Inside Postgres, in the row's transaction | Deno container, outside the database |
| How it's invoked | By the cron schedule (no caller) | eb.db.rpc('name') from the SDK, or from a cron job | Automatically, when an INSERT / UPDATE / DELETE / TRUNCATE happens on the attached table | eb.functions.invoke('name') or HTTP |
| Language | SQL (or pick an RPC) | SQL or PL/pgSQL | PL/pgSQL (return type trigger) | TypeScript / JavaScript |
| Transactional | Yes — its own transaction at run time | Yes — runs in the caller's transaction | Yes — runs in the row operation's transaction (can roll it back by raising) | No — separate process |
| Where to manage it | Cron & RPC → Scheduled Jobs | Cron & RPC → Functions | Function: Cron & RPC → Functions (Returns: trigger). Attachment: Database → table → Triggers panel | Functions tab |
| Use case examples | Daily cleanup of expired sessions; weekly digest aggregation; archiving old rows | Computed leaderboard, multi-statement bulk update, an atomic check-and-decrement, complex aggregate the SDK can call by name | Enforcing a max-N-rows-per-user constraint on INSERT; auto-stamping updated_at; mirroring inserts into an audit table | Stripe / Mollie webhook; sending email via TEM; calling an external image-processing API; OAuth callback handling |
| Pick when | You want something to run on a schedule, regardless of user activity | Your app needs to call a chunk of DB-only logic by name, atomically | A row change must always be accompanied by side-effect SQL — and the side effect must succeed or fail with the row change | You need the JS ecosystem, an external HTTP call, or anything Postgres can't reach from within the database |
Mental model: Cron jobs are scheduled SQL; RPC is callable SQL; DB triggers are reactive SQL; edge functions are everything else. The first three all run inside Postgres and share its transactional model. Edge functions are a separate runtime that talks to the DB over the SDK like your app does.
One subtle gotcha: a function that RETURNS trigger is created in the same place as RPC functions, but it won't appear in the RPC list and can't be called via eb.db.rpc(). It only exists to be attached to a table by a trigger. The Functions list filters it out so the surfaces stay honest. To attach it: Database tab → pick the table → expand the Triggers panel.
Calling an RPC function: eb.db.rpc('name', args) in @eurobase/sdk 0.12.0 or later (returns { data, error, code }), or POST /v1/db/rpc/<name> with your API key (and the user's access token, so RLS applies) and the arguments as a JSON object by name, e.g. {"mission_id": "…"}. The answer is {"result": …}. When it fails, you get {"error": …, "code": …} with the Postgres error code: your own RAISE EXCEPTION message with 400 (code P0001), a permission or RLS denial with 403 (42501), wrong arguments or bad values with 400, an unknown function with 404. Values from the database (an error's detail, e.g. a duplicate key) are left out, so the message is safe to show in your app. Your message comes through when you raise without a custom ERRCODE (or with one starting with P0); other codes get a generic message for that kind of error (e.g. “permission denied” for 42501). To see the full error, run the function in the SQL editor.
Creating a Function
From the console, navigate to Functions tab and click + New Function. Give it a lowercase name with hyphens (e.g., process-order).
Or via CLI:
eurobase edge-functions deploy process-order --file functions/process-order.ts
Function Structure
Write TypeScript or JavaScript and export your handler with export default (or module.exports). The runner passes req (a Request) and ctx (context). Code is compiled on deploy — types are stripped, not checked.
Heads-up: third-party imports (import … from "https://…" or npm packages) are not supported yet — inline dependencies into the function file.
Reading the request (webhooks & APIs)
Your function receives the full incoming request, so it can act as a webhook receiver or small HTTP API:
- Query string —
new URL(req.url).searchParams.get('token') - Custom headers —
req.headers.get('X-Signature'),X-Api-Key,X-Webhook-Id, etc. are forwarded as sent. - Body —
await req.json()/req.text();Content-Typeis preserved.
Withheld from your function so platform credentials don't leak: the auth that authenticates your call to Eurobase — the Authorization, apikey, Cookie headers and the ?apikey= query param — plus Eurobase's internal X-Eurobase-* / X-Project-* / X-Function-* / X-User-* headers. For end-user identity on a verify_jwt function, use ctx.user. Put a partner's auth token in any other custom header (e.g. X-Api-Key, X-Signature) or query param (e.g. ?token=).
TypeScript types (autocomplete + type-checking)
The full req and ctx shapes are shipped from the SDK as a types-only subpath. Import in your function source with a type-only import (bundlers elide it at build time):
import type { EdgeHandler } from '@eurobase/sdk/functions'
const handler: EdgeHandler = async (req, ctx) => {
const { orderId } = (await req.json()) as { orderId: string }
const rows = await ctx.db.sql<{ id: string; total: number }>(
"SELECT id, total FROM orders WHERE id = $1",
[orderId],
)
return Response.json(rows[0])
}
export default handler Requires @eurobase/sdk@0.7.0 or later. The exported types (EdgeContext, EdgeHandler, EdgeUser, EdgeStorageUploadResult, EdgeSignedUrlResult, EdgeLogger) mirror the runtime ctx the runner passes to your handler, so autocomplete and type-checking cover every helper. Note: ctx.db.sql(...) resolves to the bare row array — assign directly with const rows = ..., not destructured with const { rows } = ....
Example handler
module.exports = async (req, ctx) => {
// Parse the incoming request
const { orderId } = await req.json();
// Query the database (scoped to your project)
// ctx.db.sql resolves to the rows array directly.
const rows = await ctx.db.sql(
"SELECT * FROM orders WHERE id = $1",
[orderId]
);
const order = rows[0];
// Read a secret from Vault
const apiKey = await ctx.vault.get("PAYMENT_API_KEY");
// Call an external API
const payment = await fetch("https://api.mollie.com/v2/payments", {
method: "POST",
headers: { Authorization: `Bearer ${ apiKey }` },
body: JSON.stringify({ amount: order.total })
});
// Return a response
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}; Context API
| Property | Description |
|---|---|
| ctx.db.sql(query, params) | Execute SQL scoped to your project schema. Each call is one statement in its own transaction; session state (temporary tables, session-level SET, advisory locks) does not carry over to the next call. A retryable "database is busy" error means your project's connection pool was full — retry after a short delay. |
| ctx.vault.get(name) | Read an encrypted secret from Vault |
| ctx.env | Per-function environment variables |
| ctx.user.id / ctx.user.email | Authenticated user (if JWT required) |
| ctx.log.info(msg) / .warn / .error | Structured logging (visible in Logs) |
Invoking Functions
Functions are invoked via HTTP using your API key:
POST https://your-project.eurobase.app/v1/functions/process-order
Authorization: Bearer <user-jwt>
apikey: eb_pk_...
{"orderId": "abc-123"} Or via the SDK:
const { data, error } = await eurobase.functions.invoke('process-order', {
body: { orderId: 'abc-123' },
}); CLI Commands
# List edge functions eurobase edge-functions list # Deploy from local file eurobase edge-functions deploy process-order -f functions/process-order.ts # View execution logs eurobase edge-functions logs process-order # Delete a function eurobase edge-functions delete process-order
Plan Limits
| Limit | Free | Pro |
|---|---|---|
| Functions per project | 3 | 25 |
| Execution timeout | 10 seconds | 60 seconds |
| Memory per execution | 64 MB | 256 MB |
Calling your database functions (RPC)
From an edge function, call your database functions directly with ctx.db.sql, not through your project's API URL (/v1/db/rpc/…). Functions can't reach Eurobase API URLs: such a request gets no answer and runs into your timeout. ctx.db.sql needs no API key and no Vault secret.
// a function that returns rows (RETURNS TABLE / SETOF)
const players = await ctx.db.sql(
"SELECT * FROM team_roster($1)",
[teamId]
);
// a function that returns one value
const [{ result }] = await ctx.db.sql(
"SELECT accept_invitation($1, $2) AS result",
[invitationId, answer]
); - Arguments go in the array as
$1,$2, … Never build the SQL string from request input. - Who it runs as: when the function is called with a signed-in user's token, the SQL runs as that user:
auth_uid()is that user and your RLS policies for signed-in users apply, exactly as foreb.db.rpc()from your app. With JWT verification on, calls without a signed-in user are refused. With it off, anyone with your public key can call the function, and calls without a user run as the service role, so check the caller before doing privileged work. - Transactions: each
ctx.db.sqlcall is one statement in its own transaction. To change several rows together, put the steps in one database function and call that. - Errors are thrown: wrap the call in
try/catch. Your ownRAISE EXCEPTIONmessage is in the error.
Network access
Functions can call any public internet address (fetch, or raw TCP). Private and internal addresses aren't reachable, and neither are Eurobase API URLs (use ctx.db.sql and ctx.storage instead). To send email, use your email provider: its HTTPS API, or its SMTP server on a submission port with your account's login — port 2525 for now (587 and 465 are currently blocked by our hosting provider; we're working on opening them). Port 25 (direct delivery to recipients' mail servers) is blocked.
Use Cases
- Payment webhooks — Process Mollie callbacks, update order status
- External integrations — Sync data to/from other EU SaaS
- Custom auth logic — Post-signup hooks, role assignment
- Data transformation — Parse CSVs, enrich records, generate reports
- Notifications — Send emails, push notifications on events