Server actions
Typed server code in server/*.ts. An exported defineAction is callable from any page as a plain async function; it runs on the platform as the caller, after its input is checked, so row-level security and your triggers apply to everything it does.
On this page
In the browser, importing an action gives a typed stub that POSTs to /__appbricx/actions/<name>; no server code reaches the bundle. Each call carries an idempotency key, so a retried call runs once.
Server code runs in a V8 isolate: no Node built-ins, no global fetch (use ctx.http.fetch), no process or filesystem. The checks refuse those at file and line.
defineAction
defineActionfunction
A typed server action: input (a v schema, checked before the handler runs), roles (required: who may call it; "guest" lets signed-out visitors call it too) and handler(ctx, input, user). Export it from server/*.ts and call it from a page like any async function.
defineAction<I, O>(spec: ActionSpec<I, O>): Action<I, O>Parameters
| Name | Type | Description |
|---|---|---|
specrequired | ActionSpec<I, O> | { input, roles, handler }. |
- Returns
- The action: callable from the browser, run by the platform.
- Errors
- A thrown Error reaches the caller as its message (422
FUNCTION_FAILED): never put a secret in it. - Bad input is refused before the handler runs:
INVALID_INPUTnaming the field (booking: must be an id). - A caller whose role isn't in
rolesis refused.
- A thrown Error reaches the caller as its message (422
- Permissions
- Runs as the caller:
ctx.dbis subject to their row-level security and your triggers. A signed-in caller needs one ofroleseven when"guest"is listed.
// server/bookings.ts
import { defineAction, v } from "@appbricx/server";
export const confirmBooking = defineAction({
input: v.object({ booking: v.uuid(), note: v.string({ max: 200 }).optional() }),
roles: ["ops"],
handler: async (ctx, input) => {
const row = await ctx.db.update("booking", input.booking, { status: "confirmed", notes: input.note ?? null });
if (!row) throw new Error("No such booking.");
return { ref: String(row.ref), status: String(row.status) };
},
});Source: internal/kernel/appsdk/server/index.ts:218
Input schemas
v.stringfunction
Text, trimmed. min (1 means required), max (default 10,000), format uuid or email.
v.string(opts?: { min?: number; max?: number; format?: "uuid" | "email" }): Schema<string>Parameters
| Name | Type | Description |
|---|---|---|
optsoptional | { min?: number; max?: number; format?: "uuid" | "email" } |
- Returns
Schema<string>- Errors
INVALID_INPUT: must be text, is required, must be at most N characters.
- Permissions
- None.
v.string({ min: 1, max: 120 })Source: internal/kernel/appsdk/server/index.ts:88
v.uuidfunction
A row id.
v.uuid(): Schema<string>- Returns
Schema<string>- Errors
INVALID_INPUT: must be an id.
- Permissions
- None.
v.object({ booking: v.uuid() })Source: internal/kernel/appsdk/server/index.ts:89
v.emailfunction
An email address (at most 320 characters).
v.email(): Schema<string>- Returns
Schema<string>- Errors
INVALID_INPUT: must be an email address.
- Permissions
- None.
v.object({ email: v.email() })Source: internal/kernel/appsdk/server/index.ts:90
v.numberfunction
A number; a string of decimal digits is accepted and converted. min, max, integer.
v.number(opts?: { min?: number; max?: number; integer?: boolean }): Schema<number>Parameters
| Name | Type | Description |
|---|---|---|
optsoptional | { min?: number; max?: number; integer?: boolean } |
- Returns
Schema<number>- Errors
INVALID_INPUT: must be a number, at least N, at most N.
- Permissions
- None.
v.number({ min: 0, max: 100000 })Source: internal/kernel/appsdk/server/index.ts:91
v.integerfunction
A whole number with optional min and max.
v.integer(opts?: { min?: number; max?: number }): Schema<number>Parameters
| Name | Type | Description |
|---|---|---|
optsoptional | { min?: number; max?: number } |
- Returns
Schema<number>- Errors
INVALID_INPUT: must be a whole number.
- Permissions
- None.
v.integer({ min: 1, max: 5 })Source: internal/kernel/appsdk/server/index.ts:92
v.booleanfunction
true or false.
v.boolean(): Schema<boolean>- Returns
Schema<boolean>- Errors
INVALID_INPUT: must be true or false.
- Permissions
- None.
v.object({ notify: v.boolean().optional() })Source: internal/kernel/appsdk/server/index.ts:93
v.enumfunction
One of a fixed list of strings; the type is the union.
v.enum<E>(values: readonly E[]): Schema<E>Parameters
| Name | Type | Description |
|---|---|---|
valuesrequired | readonly E[] |
- Returns
Schema<E>- Errors
INVALID_INPUT: must be one of …
- Permissions
- None.
v.enum(["confirmed", "cancelled"] as const)Source: internal/kernel/appsdk/server/index.ts:98
v.arrayfunction
A list of one schema, at most max items (default 1,000).
v.array<T>(of: Schema<T>, opts?: { max?: number }): Schema<T[]>Parameters
| Name | Type | Description |
|---|---|---|
ofrequired | Schema<T> | |
optsoptional | { max?: number } |
- Returns
Schema<T[]>- Errors
INVALID_INPUT: must be a list; errors inside name the index (items.3).
- Permissions
- None.
v.array(v.uuid(), { max: 50 })Source: internal/kernel/appsdk/server/index.ts:103
v.objectfunction
An object with exactly these fields: an unknown field is refused, and .optional() fields may be left out. Every action's input is one.
v.object<F>(fields: F): Schema<{ [K in keyof F]: Infer<F[K]>; }>Parameters
| Name | Type | Description |
|---|---|---|
fieldsrequired | F |
- Returns
Schema<{ [K in keyof F]: Infer<F[K]>; }>- Errors
INVALID_INPUT: must be an object, or<field>isn't a field of this action's input.
- Permissions
- None.
v.object({ booking: v.uuid(), stars: v.integer({ min: 1, max: 5 }) })Source: internal/kernel/appsdk/server/index.ts:109
v.anyfunction
Any JSON value, unchecked. Code converted from a JSON contract starts with it; replace it with a precise schema.
v.any(): Schema<any>- Returns
Schema<any>- Errors
- Does not throw.
- Permissions
- None.
v.object({ payload: v.any() })Source: internal/kernel/appsdk/server/index.ts:87
ctx
ctx.db.getmethod
One row by id, or null (also when the caller may not read it).
ctx.db.get(table: string, id: string): Promise<Record<string, unknown> | null>Parameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
idrequired | string |
- Returns
Promise<Record<string, unknown> | null>- Errors
- Does not throw.
- Permissions
- As the caller (row-level security).
const b = await ctx.db.get("booking", input.booking);Source: internal/kernel/appsdk/server/index.ts:153
ctx.db.listmethod
Rows of a table: { where, filter, sort, limit, offset }, at most 500 per call.
ctx.db.list(table: string, opts?: Record<string, unknown>): Promise<Record<string, unknown>[]>Parameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
optsoptional | Record<string, unknown> |
- Returns
Promise<Record<string, unknown>[]>- Errors
- Does not throw.
- Permissions
- As the caller.
const open = await ctx.db.list("booking", { where: { status: "requested" }, sort: "-created_at", limit: 20 });Source: internal/kernel/appsdk/server/index.ts:154
ctx.db.createmethod
Inserts a row and returns it.
ctx.db.create(table: string, row: Record<string, unknown>): Promise<Record<string, unknown>>Parameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
rowrequired | Record<string, unknown> |
- Returns
Promise<Record<string, unknown>>- Errors
- Rejects with the policy, trigger or constraint's message.
- Permissions
- As the caller: policies'
with checkand your triggers apply.
const row = await ctx.db.create("booking", { experience_id: input.experience, team_size: input.size, email: input.email });Source: internal/kernel/appsdk/server/index.ts:155
ctx.db.updatemethod
Patches a row and returns it (null when none matched or the caller may not change it).
ctx.db.update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>Parameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
idrequired | string | |
patchrequired | Record<string, unknown> |
- Returns
Promise<Record<string, unknown>>- Errors
- Rejects with the trigger or constraint's message.
- Permissions
- As the caller.
await ctx.db.update("booking", id, { status: "confirmed" });Source: internal/kernel/appsdk/server/index.ts:156
ctx.db.removemethod
Deletes a row (soft-deletes on a ctx.soft_delete table).
ctx.db.remove(table: string, id: string): Promise<unknown>Parameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
idrequired | string |
- Returns
Promise<unknown>- Errors
- Does not throw.
- Permissions
- As the caller.
await ctx.db.remove("ops_note", input.note);Source: internal/kernel/appsdk/server/index.ts:157
ctx.memethod
The caller's row id in a role's profile table (their own role by default), or null.
ctx.me(role?: string): Promise<string | null>Parameters
| Name | Type | Description |
|---|---|---|
roleoptional | string |
- Returns
Promise<string | null>- Errors
- Does not throw.
- Permissions
- As the caller.
const hr = await ctx.me("hr");
if (!hr) throw new Error("Sign in with a company account.");Source: internal/kernel/appsdk/server/index.ts:160
ctx.configproperty
The app's config values for this environment (every config entry of app.config.ts, public or not), typed.
ctx.config: Readonly<Partial<ConfigOf>>- Returns
Readonly<Partial<ConfigOf>>- Errors
- Does not throw.
- Permissions
- Server only.
const url = ctx.config.PAYMENTS_URL + "/v1/orders";Source: internal/kernel/appsdk/server/index.ts:162
ctx.secrets.refmethod
A placeholder for a secret that ctx.http.fetch replaces with the value, only in a request to one of the secret's hosts. The code never holds the value, and values are scrubbed from logs and errors.
ctx.secrets.ref(name: SecretOf): stringParameters
| Name | Type | Description |
|---|---|---|
namerequired | SecretOf |
- Returns
string- Errors
- Does not throw.
- Permissions
- Server only; the secret must be declared in app.config.ts
secrets.
await ctx.http.fetch(ctx.config.PAYMENTS_URL + "/v1/orders", {
method: "POST",
auth: { basic: ["teamday", ctx.secrets.ref("PAYMENTS_KEY")] },
json: { amount },
});Source: internal/kernel/appsdk/server/index.ts:146
ctx.secrets.getmethod
A secret's value, for a secret declared expose: true (audited). Prefer ref.
ctx.secrets.get(name: SecretOf): Promise<string | null>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | SecretOf |
- Returns
Promise<string | null>- Errors
- Rejects for a secret not declared
expose: true.
- Rejects for a secret not declared
- Permissions
- Server only.
const key = await ctx.secrets.get("SIGNING_KEY");Source: internal/kernel/appsdk/server/index.ts:148
ctx.http.fetchmethod
An HTTPS request to a public host (30 s): method, headers, body or json, auth (basic or bearer, with ctx.secrets.ref). With app.config.ts http.allow set, only those hosts (and redirects to them) are reached.
ctx.http.fetch(url: string, init?: FetchInit): Promise<FetchResult>Parameters
| Name | Type | Description |
|---|---|---|
urlrequired | string | |
initoptional | FetchInit |
- Returns
{ status, ok, statusText, url, headers, text, json() }.- Errors
- Rejects for a private address or a host outside
http.allow.
- Rejects for a private address or a host outside
- Permissions
- Server only. There is no global fetch in server code.
const r = await ctx.http.fetch("https://api.example.com/v1/rates", { auth: { bearer: ctx.secrets.ref("RATES_KEY") } });
if (!r.ok) throw new Error("Rates are unavailable.");
const rates = r.json<{ inr: number }>();Source: internal/kernel/appsdk/server/index.ts:164
ctx.signalmethod
Wakes the workflow runs waiting in step.waitFor(name, { key }), handing them payload. A signal sent before a run reaches its wait still counts.
ctx.signal(name: string, key?: string, payload?: unknown): Promise<{ woken: number }>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | string | |
keyoptional | string | |
payloadoptional | unknown |
- Returns
{ woken }: how many runs it reached.- Errors
- Does not throw.
- Permissions
- Any caller of the action.
const { woken } = await ctx.signal("feedback", input.booking, { stars: input.stars });Source: internal/kernel/appsdk/server/index.ts:166
ctx.emailmethod
Queues an email: to (addresses), subject, body (text) or html. For templated mail use a workflow's step.send.email.
ctx.email(m: { to: string | string[]; subject: string; body?: string; html?: string }): Promise<{ queued: number }>Parameters
| Name | Type | Description |
|---|---|---|
mrequired | { to: string | string[]; subject: string; body?: string; html?: string } |
- Returns
Promise<{ queued: number }>- Errors
- Does not throw.
- Permissions
- In dev and verify, mail to anyone but the project's owners and admins is kept in the outbox, not sent (tail kind emails).
await ctx.email({ to: row.email, subject: "Your booking", body: "See you on Saturday." });Source: internal/kernel/appsdk/server/index.ts:167
ctx.smsmethod
Queues an SMS: to and body.
ctx.sms(m: { to: string | string[]; body: string }): Promise<{ queued: number }>Parameters
| Name | Type | Description |
|---|---|---|
mrequired | { to: string | string[]; body: string } |
- Returns
Promise<{ queued: number }>- Errors
- Does not throw.
- Permissions
- As ctx.email.
await ctx.sms({ to: profile.phone, body: "Your table is ready." });Source: internal/kernel/appsdk/server/index.ts:168
ctx.pdfmethod
Prints HTML as an A4 PDF with the platform's browser (no network and no scripts: put images in as data: URLs) and keeps it as a file of the app. Store url in a row: whoever can read the row can open it.
ctx.pdf(html: string, opts?: { name?: string; landscape?: boolean }): Promise<{ fileId: string; url: string; bytes: number }>Parameters
| Name | Type | Description |
|---|---|---|
htmlrequired | string | |
optsoptional | { name?: string; landscape?: boolean } |
- Returns
{ fileId, url, bytes }.- Errors
- Does not throw.
- Permissions
- Server only.
const pdf = await ctx.pdf(invoiceHtml, { name: "invoice-" + row.ref });
await ctx.db.update("invoice", row.id, { pdf: pdf.url });Source: internal/kernel/appsdk/server/index.ts:174
ctx.logmethod
A line in the run's log (logs and tail show it). Secret values are scrubbed.
ctx.log(...parts: unknown[]): voidParameters
| Name | Type | Description |
|---|---|---|
partsrequired | unknown[] |
- Returns
void- Errors
- Does not throw.
- Permissions
- None.
ctx.log("confirmed", input.booking);Source: internal/kernel/appsdk/server/index.ts:175
ctx.nowmethod
The current time.
ctx.now(): Date- Returns
Date- Errors
- Does not throw.
- Permissions
- None.
const today = ctx.now().toISOString().slice(0, 10);Source: internal/kernel/appsdk/server/index.ts:176
Types
interface ActionSpec<I, O> {
/** The input's schema: every call's input is checked before the handler runs. */
input: Schema<I>;
/** The roles that may call it; "guest" lets signed-out visitors call it too. */
roles: string[];
handler: (ctx: ServerCtx, input: I, user: User | null) => Promise<O> | O;
}Source: internal/kernel/appsdk/server/index.ts:200
interface Action<I, O> {
(input: I): Promise<O>;
readonly kind: "appbricx-action";
readonly roles: string[];
readonly input: Schema<I>;
/** The platform runs this (it checks the input first). */
run(ctx: ServerCtx, input: unknown, user: User | null): Promise<O>;
}Source: internal/kernel/appsdk/server/index.ts:209
interface User {
id: string;
name: string;
email: string;
role: string;
}Source: internal/kernel/appsdk/server/index.ts:129
interface ServerCtx {
db: {
get(table: string, id: string): Promise<Record<string, unknown> | null>;
list(table: string, opts?: Record<string, unknown>): Promise<Record<string, unknown>[]>;
create(table: string, row: Record<string, unknown>): Promise<Record<string, unknown>>;
update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
remove(table: string, id: string): Promise<unknown>;
[op: string]: (...args: never[]) => Promise<unknown>;
};
me(role?: string): Promise<string | null>;
/** The app's config values for this environment (app.config.ts config). */
config: Readonly<Partial<ConfigOf>>;
secrets: Secrets;
http: { fetch(url: string, init?: FetchInit): Promise<FetchResult> };
/** Wakes the workflow runs waiting with step.waitFor(name, { key }), handing them payload. */
signal(name: string, key?: string, payload?: unknown): Promise<{ woken: number }>;
email(m: { to: string | string[]; subject: string; body?: string; html?: string }): Promise<{ queued: number }>;
sms(m: { to: string | string[]; body: string }): Promise<{ queued: number }>;
/**
* Prints html as a PDF (A4; offline: put images in as data: URLs) and
* keeps it as a file of the app. Store url in a row (an invoice's pdf
* column): whoever can read the row can open it.
*/
pdf(html: string, opts?: { name?: string; landscape?: boolean }): Promise<{ fileId: string; url: string; bytes: number }>;
log(...parts: unknown[]): void;
now(): Date;
[key: string]: unknown;
}Source: internal/kernel/appsdk/server/index.ts:151
interface FetchInit {
method?: string;
headers?: Record<string, string>;
/** A string as is, anything else as JSON. */
body?: unknown;
/** Sent as JSON (Content-Type set). */
json?: unknown;
auth?: { basic: [string, string] } | { bearer: string };
}Source: internal/kernel/appsdk/server/index.ts:180
interface FetchResult {
status: number;
ok: boolean;
statusText: string;
url: string;
headers: Record<string, string>;
text: string;
json<T = unknown>(): T | null;
}Source: internal/kernel/appsdk/server/index.ts:190