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/react—BrivenProvider+useQuery/useMutationhooks.@briven/svelte—setBrivenClientat boot;query/mutationreturn Svelte stores.@briven/vue— same surface, Vue 3 composables returningRefs.
query vs. mutation vs. action
query()— read-only. participates in the realtime subscription pipeline: callinguseQuery("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.