page.tsx246 lines · main
| 1 | import { DocsShell } from '../../components/shell'; |
| 2 | |
| 3 | export const metadata = { title: 'functions' }; |
| 4 | |
| 5 | export default function FunctionsPage() { |
| 6 | return ( |
| 7 | <DocsShell> |
| 8 | <h1 className="font-mono text-2xl tracking-tight">functions</h1> |
| 9 | <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]"> |
| 10 | every file under <code>briven/functions/</code> becomes an invocable endpoint at{' '} |
| 11 | <code>/v1/projects/:id/functions/:name</code>. functions run in a deno isolate per |
| 12 | project, with a typed db client and the project's env vars injected. |
| 13 | </p> |
| 14 | |
| 15 | <Section title="hello world"> |
| 16 | <p> |
| 17 | file name = function name. wrap your handler with <code>query</code>,{' '} |
| 18 | <code>mutation</code>, or <code>action</code> from <code>@briven/cli/server</code>: |
| 19 | </p> |
| 20 | <Snippet>{`// briven/functions/getNotes.ts |
| 21 | import { query, type Ctx } from '@briven/cli/server'; |
| 22 | |
| 23 | interface Args { |
| 24 | authorId: string; |
| 25 | } |
| 26 | |
| 27 | export default query(async (ctx: Ctx, args: Args) => { |
| 28 | const rows = await ctx |
| 29 | .db('notes') |
| 30 | .select(['id', 'body', 'createdAt']) |
| 31 | .where({ authorId: args.authorId }) |
| 32 | .orderBy('createdAt', 'desc') |
| 33 | .limit(50); |
| 34 | return { notes: rows }; |
| 35 | });`}</Snippet> |
| 36 | <p> |
| 37 | invoke locally after deploy with <code>briven invoke getNotes --body '{`{"authorId":"u_..."}`}'</code>. |
| 38 | </p> |
| 39 | </Section> |
| 40 | |
| 41 | <Section title="client sdks"> |
| 42 | <p> |
| 43 | framework hooks/stores/composables for invoking briven functions and subscribing to |
| 44 | their reactive results: |
| 45 | </p> |
| 46 | <ul className="list-disc pl-5"> |
| 47 | <li> |
| 48 | <code>@briven/client</code> — framework-agnostic browser client (vanilla JS).{' '} |
| 49 | <code>createBrivenClient({`{...}`})</code> returns an object with{' '} |
| 50 | <code>.invoke()</code> and <code>.subscribe()</code>. |
| 51 | </li> |
| 52 | <li> |
| 53 | <code>@briven/react</code> — <code>BrivenProvider</code> +{' '} |
| 54 | <code>useQuery</code> / <code>useMutation</code> hooks. |
| 55 | </li> |
| 56 | <li> |
| 57 | <code>@briven/svelte</code> — <code>setBrivenClient</code> at boot;{' '} |
| 58 | <code>query</code> / <code>mutation</code> return Svelte stores. |
| 59 | </li> |
| 60 | <li> |
| 61 | <code>@briven/vue</code> — same surface, Vue 3 composables returning <code>Ref</code>s. |
| 62 | </li> |
| 63 | </ul> |
| 64 | </Section> |
| 65 | |
| 66 | <Section title="query vs. mutation vs. action"> |
| 67 | <ul className="list-disc pl-5"> |
| 68 | <li> |
| 69 | <code>query()</code> — read-only. participates in the realtime subscription pipeline: |
| 70 | calling <code>useQuery("getNotes", args)</code> from the client will{' '} |
| 71 | re-run the function whenever a row changes in any table the function read. |
| 72 | </li> |
| 73 | <li> |
| 74 | <code>mutation()</code> — writes. wrapped in a transaction. the realtime fan-out fires |
| 75 | on commit. |
| 76 | </li> |
| 77 | <li> |
| 78 | <code>action()</code> — for outbound side effects (calling an external api, sending an |
| 79 | email). actions can't participate in subscriptions; their result isn't cached. |
| 80 | </li> |
| 81 | </ul> |
| 82 | </Section> |
| 83 | |
| 84 | <Section title="the Ctx object"> |
| 85 | <p>every handler receives a typed <code>ctx</code> as its first argument:</p> |
| 86 | <Snippet>{`interface Ctx { |
| 87 | db: DbClient; // typed query builder, project-scoped |
| 88 | requestId: string; // stable per request, correlated in logs |
| 89 | log: Logger; // structured json logger; never log customer data |
| 90 | env: Readonly<Record<string, string | undefined>>; |
| 91 | auth: { userId: string; tokenType: 'session' | 'api_key' } | null; |
| 92 | }`}</Snippet> |
| 93 | </Section> |
| 94 | |
| 95 | <Section title="db builder"> |
| 96 | <p> |
| 97 | <code>ctx.db(table)</code> returns a chainable builder. the surface is a focused subset |
| 98 | — the 90% path of select / insert / update / delete: |
| 99 | </p> |
| 100 | <Snippet>{`// select |
| 101 | await ctx.db('notes').select(['id', 'body']).where({ status: 'pinned' }); |
| 102 | |
| 103 | // insert (returning is optional) |
| 104 | const [row] = await ctx.db('notes').insert({ id, body }).returning(); |
| 105 | |
| 106 | // update |
| 107 | await ctx.db('notes').update({ status: 'archived' }).where({ id }); |
| 108 | |
| 109 | // delete |
| 110 | await ctx.db('notes').delete().where({ id }); |
| 111 | |
| 112 | // raw escape hatch for anything the builder doesn't cover |
| 113 | await ctx.db.execute('select count(*) from notes where created_at > $1', [cutoff]);`}</Snippet> |
| 114 | </Section> |
| 115 | |
| 116 | <Section title="common patterns"> |
| 117 | <p> |
| 118 | recipes for the things you'll write five times a week. all of them stay inside |
| 119 | the supported builder surface above — drop to <code>ctx.db.execute</code> only when |
| 120 | the case truly is one-off. |
| 121 | </p> |
| 122 | |
| 123 | <h3 className="mt-4 font-mono text-sm text-[var(--color-text)]">cursor pagination</h3> |
| 124 | <p> |
| 125 | offset pagination breaks for fast-changing feeds (rows shift between pages). cursor |
| 126 | off the indexed sort column instead — typically <code>createdAt</code>: |
| 127 | </p> |
| 128 | <Snippet>{`interface Args { cursor?: string; limit?: number } |
| 129 | |
| 130 | export default query(async (ctx, args: Args = {}) => { |
| 131 | const limit = Math.min(args.limit ?? 50, 200); |
| 132 | let q = ctx.db('posts').select(['id', 'body', 'createdAt']); |
| 133 | if (args.cursor) q = q.where('createdAt', '<', new Date(args.cursor)); |
| 134 | const rows = await q.orderBy('createdAt', 'desc').limit(limit + 1); |
| 135 | const next = rows.length > limit ? rows[limit - 1].createdAt.toISOString() : null; |
| 136 | return { rows: rows.slice(0, limit), nextCursor: next }; |
| 137 | });`}</Snippet> |
| 138 | |
| 139 | <h3 className="mt-4 font-mono text-sm text-[var(--color-text)]">case-insensitive search</h3> |
| 140 | <p> |
| 141 | for prefix / fuzzy match across a small column use <code>ilike</code>; for full-text |
| 142 | search at scale, add a generated <code>tsvector</code> column and an index. |
| 143 | </p> |
| 144 | <Snippet>{`interface Args { q: string } |
| 145 | |
| 146 | export default query(async (ctx, args: Args) => { |
| 147 | const needle = '%' + args.q.replace(/[%_]/g, '') + '%'; |
| 148 | return ctx.db('posts') |
| 149 | .select(['id', 'title']) |
| 150 | .where('title', 'ilike', needle) |
| 151 | .orderBy('createdAt', 'desc') |
| 152 | .limit(50); |
| 153 | });`}</Snippet> |
| 154 | |
| 155 | <h3 className="mt-4 font-mono text-sm text-[var(--color-text)]">transactional mutation</h3> |
| 156 | <p> |
| 157 | every mutation already runs inside a transaction — the runtime wraps your handler. |
| 158 | throws roll back the whole thing. if you need to fail explicitly, throw a{' '} |
| 159 | <code>brivenError</code> (see <em>errors</em> below). |
| 160 | </p> |
| 161 | <Snippet>{`export default mutation(async (ctx, args: { fromId: string; toId: string; amount: number }) => { |
| 162 | await ctx.db('accounts').decrement('balance', args.amount).where({ id: args.fromId }); |
| 163 | await ctx.db('accounts').increment('balance', args.amount).where({ id: args.toId }); |
| 164 | // both updates commit together. if the second throws, the first is rolled back. |
| 165 | });`}</Snippet> |
| 166 | |
| 167 | <h3 className="mt-4 font-mono text-sm text-[var(--color-text)]">user logs</h3> |
| 168 | <p> |
| 169 | <code>ctx.log(msg, fields?)</code> writes a structured entry to{' '} |
| 170 | <code>function_logs.user_logs_json</code>. it's the only thing surfaced in the |
| 171 | dashboard's logs tab, so use it instead of <code>console.log</code>: |
| 172 | </p> |
| 173 | <Snippet>{`export default mutation(async (ctx, args: { orderId: string }) => { |
| 174 | const order = await ctx.db('orders').select(['*']).where({ id: args.orderId }).first(); |
| 175 | ctx.log('processing order', { orderId: order.id, total: order.totalCents }); |
| 176 | // the dashboard logs tab will show this entry. |
| 177 | });`}</Snippet> |
| 178 | |
| 179 | <h3 className="mt-4 font-mono text-sm text-[var(--color-text)]">caller identity</h3> |
| 180 | <p> |
| 181 | <code>ctx.auth</code> is populated from the bearer key / session. use it as the |
| 182 | identity input to your authorization checks; never trust args for who the caller is. |
| 183 | </p> |
| 184 | <Snippet>{`export default query(async (ctx) => { |
| 185 | if (!ctx.auth) throw new brivenError('unauthorized', 'sign in to view', { status: 401 }); |
| 186 | return ctx.db('notes').select(['id', 'body']).where({ ownerId: ctx.auth.userId }); |
| 187 | });`}</Snippet> |
| 188 | </Section> |
| 189 | |
| 190 | <Section title="env vars"> |
| 191 | <p> |
| 192 | set with <code>briven env put KEY value</code>. read inside a function as{' '} |
| 193 | <code>ctx.env.KEY</code>. encrypted at rest, decrypted only when the runtime spawns an |
| 194 | isolate, never logged. |
| 195 | </p> |
| 196 | </Section> |
| 197 | |
| 198 | <Section title="errors"> |
| 199 | <p> |
| 200 | throw to fail. the runtime serialises the error as <code>{`{ ok: false, code, message }`}</code>{' '} |
| 201 | and returns a 500. throw a structured error to set <code>code</code>: |
| 202 | </p> |
| 203 | <Snippet>{`import { brivenError } from '@briven/shared'; |
| 204 | |
| 205 | export default mutation(async (ctx, args) => { |
| 206 | if (!args.body?.trim()) { |
| 207 | throw new brivenError('validation_failed', 'body is required', { status: 400 }); |
| 208 | } |
| 209 | // ... |
| 210 | });`}</Snippet> |
| 211 | </Section> |
| 212 | |
| 213 | <Section title="lifecycle + scaling"> |
| 214 | <p> |
| 215 | one deno isolate per project, warm-cached and pooled. cold start is under 200ms p50. |
| 216 | isolates are killed and replaced on crash, after 10 minutes idle, or after 1,000 |
| 217 | invocations — whichever trips first. there is no cross-project state in any isolate. |
| 218 | </p> |
| 219 | <p> |
| 220 | outbound network is denied by default for rfc1918 (private), link-local, and the cloud |
| 221 | metadata endpoints. customer functions can call any public endpoint; bring up an |
| 222 | allowlist via the dashboard if you need stricter egress. |
| 223 | </p> |
| 224 | </Section> |
| 225 | </DocsShell> |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | function Section({ title, children }: { title: string; children: React.ReactNode }) { |
| 230 | return ( |
| 231 | <section className="mt-10"> |
| 232 | <h2 className="font-mono text-lg">{title}</h2> |
| 233 | <div className="mt-2 space-y-3 font-mono text-sm text-[var(--color-text-muted)]"> |
| 234 | {children} |
| 235 | </div> |
| 236 | </section> |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | function Snippet({ children }: { children: string }) { |
| 241 | return ( |
| 242 | <pre className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs"> |
| 243 | <code>{children}</code> |
| 244 | </pre> |
| 245 | ); |
| 246 | } |