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.

@appbricx/server27 membersSDK referenceServer actions guide

On this page
  1. defineAction
  2. defineAction
  3. Input schemas
  4. v.string
  5. v.uuid
  6. v.email
  7. v.number
  8. v.integer
  9. v.boolean
  10. v.enum
  11. v.array
  12. v.object
  13. v.any
  14. ctx
  15. ctx.db.get
  16. ctx.db.list
  17. ctx.db.create
  18. ctx.db.update
  19. ctx.db.remove
  20. ctx.me
  21. ctx.config
  22. ctx.secrets.ref
  23. ctx.secrets.get
  24. ctx.http.fetch
  25. ctx.signal
  26. ctx.email
  27. ctx.sms
  28. ctx.pdf
  29. ctx.log
  30. ctx.now
  31. Types

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

NameTypeDescription
specrequiredActionSpec<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_INPUT naming the field (booking: must be an id).
  • A caller whose role isn't in roles is refused.
Permissions
Runs as the caller: ctx.db is subject to their row-level security and your triggers. A signed-in caller needs one of roles even when "guest" is listed.
Example
// 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

NameTypeDescription
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.
Example
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.
Example
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.
Example
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

NameTypeDescription
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.
Example
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

NameTypeDescription
optsoptional{ min?: number; max?: number }
Returns
Schema<number>
Errors
  • INVALID_INPUT: must be a whole number.
Permissions
None.
Example
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.
Example
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

NameTypeDescription
valuesrequiredreadonly E[]
Returns
Schema<E>
Errors
  • INVALID_INPUT: must be one of …
Permissions
None.
Example
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

NameTypeDescription
ofrequiredSchema<T>
optsoptional{ max?: number }
Returns
Schema<T[]>
Errors
  • INVALID_INPUT: must be a list; errors inside name the index (items.3).
Permissions
None.
Example
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

NameTypeDescription
fieldsrequiredF
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.
Example
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.
Example
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

NameTypeDescription
tablerequiredstring
idrequiredstring
Returns
Promise<Record<string, unknown> | null>
Errors
Does not throw.
Permissions
As the caller (row-level security).
Example
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

NameTypeDescription
tablerequiredstring
optsoptionalRecord<string, unknown>
Returns
Promise<Record<string, unknown>[]>
Errors
Does not throw.
Permissions
As the caller.
Example
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

NameTypeDescription
tablerequiredstring
rowrequiredRecord<string, unknown>
Returns
Promise<Record<string, unknown>>
Errors
  • Rejects with the policy, trigger or constraint's message.
Permissions
As the caller: policies' with check and your triggers apply.
Example
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

NameTypeDescription
tablerequiredstring
idrequiredstring
patchrequiredRecord<string, unknown>
Returns
Promise<Record<string, unknown>>
Errors
  • Rejects with the trigger or constraint's message.
Permissions
As the caller.
Example
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

NameTypeDescription
tablerequiredstring
idrequiredstring
Returns
Promise<unknown>
Errors
Does not throw.
Permissions
As the caller.
Example
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

NameTypeDescription
roleoptionalstring
Returns
Promise<string | null>
Errors
Does not throw.
Permissions
As the caller.
Example
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.
Example
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): string

Parameters

NameTypeDescription
namerequiredSecretOf
Returns
string
Errors
Does not throw.
Permissions
Server only; the secret must be declared in app.config.ts secrets.
Example
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

NameTypeDescription
namerequiredSecretOf
Returns
Promise<string | null>
Errors
  • Rejects for a secret not declared expose: true.
Permissions
Server only.
Example
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

NameTypeDescription
urlrequiredstring
initoptionalFetchInit
Returns
{ status, ok, statusText, url, headers, text, json() }.
Errors
  • Rejects for a private address or a host outside http.allow.
Permissions
Server only. There is no global fetch in server code.
Example
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

NameTypeDescription
namerequiredstring
keyoptionalstring
payloadoptionalunknown
Returns
{ woken }: how many runs it reached.
Errors
Does not throw.
Permissions
Any caller of the action.
Example
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

NameTypeDescription
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).
Example
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

NameTypeDescription
mrequired{ to: string | string[]; body: string }
Returns
Promise<{ queued: number }>
Errors
Does not throw.
Permissions
As ctx.email.
Example
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

NameTypeDescription
htmlrequiredstring
optsoptional{ name?: string; landscape?: boolean }
Returns
{ fileId, url, bytes }.
Errors
Does not throw.
Permissions
Server only.
Example
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[]): void

Parameters

NameTypeDescription
partsrequiredunknown[]
Returns
void
Errors
Does not throw.
Permissions
None.
Example
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.
Example
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