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 JobRPC FunctionDB TriggerEdge Function
What it isA schedule that runs SQL or an RPC at a cron expressionA reusable Postgres function (SQL/PL-pgSQL) you call by nameA Postgres function bound to a row event on a tableServerless TS/JS in a Deno container
Where it runsInside Postgres (cron worker fires the SQL/RPC)Inside PostgresInside Postgres, in the row's transactionDeno container, outside the database
How it's invokedBy the cron schedule (no caller)eb.db.rpc('name') from the SDK, or from a cron jobAutomatically, when an INSERT / UPDATE / DELETE / TRUNCATE happens on the attached tableeb.functions.invoke('name') or HTTP
LanguageSQL (or pick an RPC)SQL or PL/pgSQLPL/pgSQL (return type trigger)TypeScript / JavaScript
TransactionalYes — its own transaction at run timeYes — runs in the caller's transactionYes — runs in the row operation's transaction (can roll it back by raising)No — separate process
Where to manage itCron & RPC → Scheduled JobsCron & RPC → FunctionsFunction: Cron & RPC → Functions (Returns: trigger). Attachment: Database → table → Triggers panelFunctions tab
Use case examplesDaily cleanup of expired sessions; weekly digest aggregation; archiving old rowsComputed leaderboard, multi-statement bulk update, an atomic check-and-decrement, complex aggregate the SDK can call by nameEnforcing a max-N-rows-per-user constraint on INSERT; auto-stamping updated_at; mirroring inserts into an audit tableStripe / Mollie webhook; sending email via TEM; calling an external image-processing API; OAuth callback handling
Pick whenYou want something to run on a schedule, regardless of user activityYour app needs to call a chunk of DB-only logic by name, atomicallyA row change must always be accompanied by side-effect SQL — and the side effect must succeed or fail with the row changeYou 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-Type is 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

PropertyDescription
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.envPer-function environment variables
ctx.user.id / ctx.user.emailAuthenticated user (if JWT required)
ctx.log.info(msg) / .warn / .errorStructured 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

LimitFreePro
Functions per project325
Execution timeout10 seconds60 seconds
Memory per execution64 MB256 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 for eb.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.sql call 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 own RAISE EXCEPTION message 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