functions

every file under briven/functions/ becomes an invocable endpoint at /v1/projects/:id/functions/:name. functions run in a deno isolate per project, with a typed db client and the project's env vars injected.

hello world

file name = function name. wrap your handler with query, mutation, or action from @briven/cli/server:

// briven/functions/getNotes.ts
import { query, type Ctx } from '@briven/cli/server';

interface Args {
  authorId: string;
}

export default query(async (ctx: Ctx, args: Args) => {
  const rows = await ctx
    .db('notes')
    .select(['id', 'body', 'createdAt'])
    .where({ authorId: args.authorId })
    .orderBy('createdAt', 'desc')
    .limit(50);
  return { notes: rows };
});

invoke locally after deploy with briven invoke getNotes --body '{"authorId":"u_..."}'.

client sdks

framework hooks/stores/composables for invoking briven functions and subscribing to their reactive results:

  • @briven/client — framework-agnostic browser client (vanilla JS). createBrivenClient({...}) returns an object with .invoke() and .subscribe().
  • @briven/reactBrivenProvider + useQuery / useMutation hooks.
  • @briven/sveltesetBrivenClient at boot; query / mutation return Svelte stores.
  • @briven/vue — same surface, Vue 3 composables returning Refs.

query vs. mutation vs. action

  • query() — read-only. participates in the realtime subscription pipeline: calling useQuery("getNotes", args) from the client will re-run the function whenever a row changes in any table the function read.
  • mutation() — writes. wrapped in a transaction. the realtime fan-out fires on commit.
  • action()— for outbound side effects (calling an external api, sending an email). actions can't participate in subscriptions; their result isn't cached.

the Ctx object

every handler receives a typed ctx as its first argument:

interface Ctx {
  db: DbClient;        // typed query builder, project-scoped
  requestId: string;   // stable per request, correlated in logs
  log: Logger;         // structured json logger; never log customer data
  env: Readonly<Record<string, string | undefined>>;
  auth: { userId: string; tokenType: 'session' | 'api_key' } | null;
}

db builder

ctx.db(table) returns a chainable builder. the surface is a focused subset — the 90% path of select / insert / update / delete:

// select
await ctx.db('notes').select(['id', 'body']).where({ status: 'pinned' });

// insert (returning is optional)
const [row] = await ctx.db('notes').insert({ id, body }).returning();

// update
await ctx.db('notes').update({ status: 'archived' }).where({ id });

// delete
await ctx.db('notes').delete().where({ id });

// raw escape hatch for anything the builder doesn't cover
await ctx.db.execute('select count(*) from notes where created_at > $1', [cutoff]);

common patterns

recipes for the things you'll write five times a week. all of them stay inside the supported builder surface above — drop to ctx.db.execute only when the case truly is one-off.

cursor pagination

offset pagination breaks for fast-changing feeds (rows shift between pages). cursor off the indexed sort column instead — typically createdAt:

interface Args { cursor?: string; limit?: number }

export default query(async (ctx, args: Args = {}) => {
  const limit = Math.min(args.limit ?? 50, 200);
  let q = ctx.db('posts').select(['id', 'body', 'createdAt']);
  if (args.cursor) q = q.where('createdAt', '<', new Date(args.cursor));
  const rows = await q.orderBy('createdAt', 'desc').limit(limit + 1);
  const next = rows.length > limit ? rows[limit - 1].createdAt.toISOString() : null;
  return { rows: rows.slice(0, limit), nextCursor: next };
});

case-insensitive search

for prefix / fuzzy match across a small column use ilike; for full-text search at scale, add a generated tsvector column and an index.

interface Args { q: string }

export default query(async (ctx, args: Args) => {
  const needle = '%' + args.q.replace(/[%_]/g, '') + '%';
  return ctx.db('posts')
    .select(['id', 'title'])
    .where('title', 'ilike', needle)
    .orderBy('createdAt', 'desc')
    .limit(50);
});

transactional mutation

every mutation already runs inside a transaction — the runtime wraps your handler. throws roll back the whole thing. if you need to fail explicitly, throw a brivenError (see errors below).

export default mutation(async (ctx, args: { fromId: string; toId: string; amount: number }) => {
  await ctx.db('accounts').decrement('balance', args.amount).where({ id: args.fromId });
  await ctx.db('accounts').increment('balance', args.amount).where({ id: args.toId });
  // both updates commit together. if the second throws, the first is rolled back.
});

user logs

ctx.log(msg, fields?) writes a structured entry to function_logs.user_logs_json. it's the only thing surfaced in the dashboard's logs tab, so use it instead of console.log:

export default mutation(async (ctx, args: { orderId: string }) => {
  const order = await ctx.db('orders').select(['*']).where({ id: args.orderId }).first();
  ctx.log('processing order', { orderId: order.id, total: order.totalCents });
  // the dashboard logs tab will show this entry.
});

caller identity

ctx.auth is populated from the bearer key / session. use it as the identity input to your authorization checks; never trust args for who the caller is.

export default query(async (ctx) => {
  if (!ctx.auth) throw new brivenError('unauthorized', 'sign in to view', { status: 401 });
  return ctx.db('notes').select(['id', 'body']).where({ ownerId: ctx.auth.userId });
});

env vars

set with briven env put KEY value. read inside a function as ctx.env.KEY. encrypted at rest, decrypted only when the runtime spawns an isolate, never logged.

errors

throw to fail. the runtime serialises the error as { ok: false, code, message } and returns a 500. throw a structured error to set code:

import { brivenError } from '@briven/shared';

export default mutation(async (ctx, args) => {
  if (!args.body?.trim()) {
    throw new brivenError('validation_failed', 'body is required', { status: 400 });
  }
  // ...
});

lifecycle + scaling

one deno isolate per project, warm-cached and pooled. cold start is under 200ms p50. isolates are killed and replaced on crash, after 10 minutes idle, or after 1,000 invocations — whichever trips first. there is no cross-project state in any isolate.

outbound network is denied by default for rfc1918 (private), link-local, and the cloud metadata endpoints. customer functions can call any public endpoint; bring up an allowlist via the dashboard if you need stricter egress.