superapp
Backend

Actions

Typed server-side functions callable by the client, with full access to the database, user session, and any table.

Actions are named server-side functions defined at the engine level. Unlike permissions (which are bound to a single table), actions can touch any table, run multi-table transactions, and execute arbitrary business logic. The client calls them by name — no raw SQL.

Actions use Zod schemas for input and output validation, and the types flow end-to-end: the schema endpoint exposes them, the CLI generates TypeScript types, and the client gets full autocomplete on db.action() calls.

import { z } from 'zod'

const engine = createEngine({
  connections: { main: { type: 'postgres', url: process.env.PG_URL! } },
  permissions: {
    view_products: { /* ... */ },
    edit_products: { /* ... */ },
  },
  actions: {
    incrementStock: {
      input: z.object({ productId: z.string(), amount: z.number().positive() }),
      output: z.object({ id: z.string(), stock: z.number() }),
      run: async ({ user, db }, { productId, amount }) => {
        const [updated] = await db
          .update(products)
          .set({ stock: sql`stock + ${amount}` })
          .where(eq(products.id, productId))
          .returning({ id: products.id, stock: products.stock })
        return updated
      },
    },
  },
  roles: {
    warehouse_manager: ['view_products', 'edit_products', 'action_incrementStock'],
    customer: ['view_products'],
  },
})
// Client — fully typed input and output
const result = await db.action('incrementStock', {
  productId: 'prod_123',
  amount: 5,
})
// result: { id: string; stock: number }

How It Works

  1. Client sends POST /actions/{actionName} with a JSON body
  2. Engine authenticates the user and resolves the session
  3. Engine checks if action_{actionName} is in the user's role array
  4. Engine validates the input against the Zod schema — rejects with 400 if invalid
  5. Engine calls run with { user, db } and the validated input
  6. Engine validates the output against the Zod schema (if defined)
  7. Return value is sent back to the client as JSON
  POST /actions/incrementStock + Bearer JWT + { productId, amount }


  1. Auth — JWT verification, session resolution


  2. Role check — is 'action_incrementStock' in the user's role array?


  3. Input validation — parse input against Zod schema


  4. Execute — run({ user, db }, validatedInput)


  5. Response — return value sent as JSON

Defining Actions

Each action has three fields:

FieldTypeRequiredDescription
inputZodSchemaYesZod schema that validates and types the client's request body
outputZodSchemaNoZod schema that validates and types the return value
run({ user, db }, input) => Promise<Output>YesThe function that executes the action
import { z } from 'zod'

const engine = createEngine({
  connections: { /* ... */ },
  permissions: { /* ... */ },
  actions: {
    myAction: {
      input: z.object({ id: z.string() }),
      output: z.object({ success: z.boolean() }),
      run: async ({ user, db }, { id }) => {
        // input is typed as { id: string }
        return { success: true }
        // return is typed as { success: boolean }
      },
    },
  },
  roles: {
    editor: ['view_orders', 'edit_orders', 'action_myAction'],
    admin: ['view_orders', 'edit_orders', 'delete_orders', 'action_myAction'],
  },
})

Action names are defined without a prefix (e.g., myAction), but referenced in roles with the action_ prefix (e.g., action_myAction). This makes it immediately clear which entries are table permissions and which are server-side functions.

The run function receives:

ParameterTypeDescription
userUserSessionResolved session (same as $user.* in filters)
dbDrizzleInstanceDrizzle query builder — run any query, any table
inputz.infer<typeof input>The client's request body, validated and typed by the input schema

If the function returns nothing, the client receives { ok: true }.

Type Safety

Actions are type-safe end-to-end — from engine definition to client call.

1. Schema Endpoint

The /schema endpoint includes action definitions with their input/output JSON schemas:

{
  "connections": { "..." },
  "actions": {
    "incrementStock": {
      "input": {
        "type": "object",
        "properties": {
          "productId": { "type": "string" },
          "amount": { "type": "number" }
        },
        "required": ["productId", "amount"]
      },
      "output": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "stock": { "type": "number" }
        },
        "required": ["id", "stock"]
      }
    }
  }
}

2. Type Generation

The CLI generates action types alongside table types:

npx superapp generate --url http://localhost:3001
// generated/schema.ts — auto-generated, do not edit

export interface SuperAppSchema {
  main: {
    orders: { id: string; amount: number; status: string }
    products: { id: string; name: string; stock: number }
  }
}

export interface SuperAppActions {
  incrementStock: {
    input: { productId: string; amount: number }
    output: { id: string; stock: number }
  }
  decrementStock: {
    input: { productId: string; amount: number }
    output: { id: string; stock: number }
  }
  resetStock: {
    input: { productId: string }
    output: void
  }
}

3. Client Usage

Pass both types to drizzle() for fully typed queries and actions:

import { drizzle } from '@superapp/db'
import * as schema from './generated/schema'
import type { SuperAppActions } from './generated/schema'

const db = drizzle<SuperAppActions>({
  connection: 'http://localhost:3001',
  token: session.token,
  schema,
})

// Full autocomplete on action name, input, and output
const result = await db.action('incrementStock', {
  productId: 'prod_123',  // ← autocomplete
  amount: 5,              // ← autocomplete
})
// result: { id: string; stock: number }

Calling an action that doesn't exist or passing wrong input types is a compile-time error:

// ✗ Type error — 'unknownAction' does not exist
await db.action('unknownAction', {})

// ✗ Type error — 'amount' must be number, not string
await db.action('incrementStock', { productId: 'prod_123', amount: '5' })

Client API

const result = await db.action('incrementStock', { ...params })

This sends:

POST /actions/incrementStock
Authorization: Bearer <jwt>
Content-Type: application/json

{ ...params }

Examples

Inventory: Increment, Decrement, and Reset Stock

A warehouse management system where operators scan items in and out. The client shouldn't write raw SET stock = stock + 1 SQL — the server controls the atomic update and validates stock bounds. Each operation is a separate action with clear naming.

import { z } from 'zod'

actions: {
  incrementStock: {
    input: z.object({ productId: z.string(), amount: z.number().positive() }),
    output: z.object({ id: z.string(), stock: z.number() }),
    run: async ({ user, db }, { productId, amount }) => {
      const [updated] = await db
        .update(products)
        .set({
          stock: sql`stock + ${amount}`,
          lastUpdatedBy: user.id,
        })
        .where(eq(products.id, productId))
        .returning({ id: products.id, stock: products.stock })

      return updated
    },
  },

  decrementStock: {
    input: z.object({ productId: z.string(), amount: z.number().positive() }),
    output: z.object({ id: z.string(), stock: z.number() }),
    run: async ({ user, db }, { productId, amount }) => {
      const product = await db.query.products.findFirst({
        where: eq(products.id, productId),
      })
      if (!product) throw new PermissionError('Product not found')
      if (product.stock < amount) {
        throw new PermissionError(`Only ${product.stock} units available`)
      }

      const [updated] = await db
        .update(products)
        .set({
          stock: sql`stock - ${amount}`,
          lastUpdatedBy: user.id,
        })
        .where(eq(products.id, productId))
        .returning({ id: products.id, stock: products.stock })

      return updated
    },
  },

  resetStock: {
    input: z.object({ productId: z.string() }),
    run: async ({ user, db }, { productId }) => {
      await db
        .update(products)
        .set({
          stock: 0,
          lastResetBy: user.id,
          lastResetAt: new Date(),
        })
        .where(eq(products.id, productId))
    },
  },
},
roles: {
  warehouse_manager: ['view_products', 'edit_products', 'action_incrementStock', 'action_decrementStock'],
  admin: ['view_products', 'edit_products', 'action_incrementStock', 'action_decrementStock', 'action_resetStock'],
}
// Client — typed input and output
const updated = await db.action('incrementStock', { productId: 'prod_123', amount: 10 })
// updated: { id: string; stock: number }

await db.action('decrementStock', { productId: 'prod_123', amount: 3 })
await db.action('resetStock', { productId: 'prod_123' })

Finance: Transfer Balance Between Accounts

A fintech app where users transfer money between their own accounts. The transfer must be atomic — debit and credit must both succeed or both fail. This is a textbook case for actions: it touches two rows in the same table, requires row locking, and creates a record in a separate transactions table — none of which maps to a single UPDATE statement.

actions: {
  transfer: {
    input: z.object({
      fromAccountId: z.string(),
      toAccountId: z.string(),
      amount: z.number().positive(),
    }),
    output: z.object({
      id: z.string(),
      fromAccountId: z.string(),
      toAccountId: z.string(),
      amount: z.number(),
      timestamp: z.string(),
    }),
    run: async ({ user, db }, { fromAccountId, toAccountId, amount }) => {
      return db.transaction(async (tx) => {
        const [source] = await tx
          .select()
          .from(accounts)
          .where(and(eq(accounts.id, fromAccountId), eq(accounts.ownerId, user.id)))
          .for('update')

        if (!source) throw new PermissionError('Source account not found')
        if (source.balance < amount) throw new PermissionError('Insufficient funds')

        const [dest] = await tx
          .select()
          .from(accounts)
          .where(eq(accounts.id, toAccountId))
          .for('update')

        if (!dest) throw new PermissionError('Destination account not found')

        await tx.update(accounts).set({ balance: sql`balance - ${amount}` }).where(eq(accounts.id, fromAccountId))
        await tx.update(accounts).set({ balance: sql`balance + ${amount}` }).where(eq(accounts.id, toAccountId))

        const [record] = await tx
          .insert(transactions)
          .values({ fromAccountId, toAccountId, amount, initiatedBy: user.id, timestamp: new Date() })
          .returning()

        return record
      })
    },
  },
},
roles: {
  account_holder: ['view_accounts', 'view_transactions', 'action_transfer'],
}
// Client
const tx = await db.action('transfer', {
  fromAccountId: 'acc_checking',
  toAccountId: 'acc_savings',
  amount: 500,
})
// tx: { id: string; fromAccountId: string; toAccountId: string; amount: number; timestamp: string }

E-Commerce: Apply Discount Code

An online store where the client sends a discount code and the server validates it against the discount_codes table, checks expiration and usage limits, computes the discount amount (percentage or fixed), applies it to the order, and increments the usage counter. All in one transaction — if any step fails, nothing changes.

actions: {
  applyDiscount: {
    input: z.object({ orderId: z.string(), code: z.string().min(1) }),
    output: z.object({ discountAmount: z.number(), newTotal: z.number() }),
    run: async ({ user, db }, { orderId, code }) => {
      return db.transaction(async (tx) => {
        const order = await tx.query.orders.findFirst({
          where: and(
            eq(orders.id, orderId),
            eq(orders.customerId, user.id),
            eq(orders.status, 'draft'),
          ),
        })
        if (!order) throw new PermissionError('Order not found or not editable')

        const discount = await tx.query.discountCodes.findFirst({
          where: and(
            eq(discountCodes.code, code.toUpperCase()),
            gt(discountCodes.expiresAt, new Date()),
            lt(discountCodes.usageCount, discountCodes.usageLimit),
          ),
        })
        if (!discount) throw new PermissionError('Invalid or expired discount code')

        const discountAmount = discount.type === 'percentage'
          ? order.subtotal * (discount.value / 100)
          : discount.value

        const applied = Math.min(discountAmount, order.subtotal)

        await tx.update(orders).set({
          discountCode: code.toUpperCase(),
          discountAmount: applied,
          total: order.subtotal - applied,
        }).where(eq(orders.id, orderId))

        await tx.update(discountCodes)
          .set({ usageCount: sql`usage_count + 1` })
          .where(eq(discountCodes.id, discount.id))

        return { discountAmount: applied, newTotal: order.subtotal - applied }
      })
    },
  },
},
roles: {
  customer: ['view_products', 'view_own_orders', 'action_applyDiscount'],
}
// Client
const { discountAmount, newTotal } = await db.action('applyDiscount', {
  orderId: 'ord_456',
  code: 'SUMMER20',
})
// discountAmount: number, newTotal: number

Team Management: Invite Member with Email Notification

A multi-tenant app where admins invite new members to their organization. The action validates the email isn't already a member, enforces role hierarchy (only admins can invite admins), creates the membership record, and queues an invite email. This spans members and email_jobs tables — clearly not a single-table CRUD operation.

actions: {
  inviteMember: {
    input: z.object({
      email: z.string().email(),
      role: z.enum(['viewer', 'editor', 'admin']),
    }),
    output: z.object({ memberId: z.string(), status: z.literal('invited') }),
    run: async ({ user, db }, { email, role }) => {
      if (role === 'admin' && !user.roles.includes('owner')) {
        throw new PermissionError('Only owners can invite admins')
      }

      const existing = await db.query.members.findFirst({
        where: and(
          eq(members.organizationId, user.current_org_id),
          eq(members.email, email.toLowerCase()),
        ),
      })
      if (existing) throw new PermissionError('Already a member')

      const [member] = await db.insert(members).values({
        organizationId: user.current_org_id,
        email: email.toLowerCase(),
        role,
        status: 'invited',
        invitedBy: user.id,
        invitedAt: new Date(),
      }).returning()

      await db.insert(emailJobs).values({
        to: email.toLowerCase(),
        template: 'org-invite',
        data: { inviterName: user.name, orgName: user.org_name, role },
      })

      return { memberId: member.id, status: 'invited' as const }
    },
  },
},
roles: {
  admin: ['view_members', 'edit_members', 'action_inviteMember'],
  owner: ['view_members', 'edit_members', 'delete_members', 'action_inviteMember'],
}
// Client
const result = await db.action('inviteMember', {
  email: 'alice@example.com',
  role: 'editor',
})
// result: { memberId: string; status: 'invited' }

Analytics: Server-Side Aggregation Report

A dashboard where the client requests a revenue report. The aggregation query uses DATE_TRUNC, SUM, AVG, and GROUP BY — complex SQL that shouldn't be expressed on the client. The action runs a pre-defined query scoped to the user's organization and returns the computed result. No writes, just a controlled read that returns aggregated data.

actions: {
  revenueReport: {
    input: z.object({
      startDate: z.string().date(),
      endDate: z.string().date(),
    }),
    output: z.array(z.object({
      month: z.string(),
      totalRevenue: z.number(),
      orderCount: z.number(),
      avgOrderValue: z.number(),
    })),
    run: async ({ user, db }, { startDate, endDate }) => {
      return db
        .select({
          month: sql`DATE_TRUNC('month', ${orders.createdAt})`.as('month'),
          totalRevenue: sql`SUM(${orders.total})`.as('total_revenue'),
          orderCount: count(),
          avgOrderValue: sql`AVG(${orders.total})`.as('avg_order_value'),
        })
        .from(orders)
        .where(
          and(
            eq(orders.organizationId, user.current_org_id),
            gte(orders.createdAt, new Date(startDate)),
            lte(orders.createdAt, new Date(endDate)),
            eq(orders.status, 'completed'),
          ),
        )
        .groupBy(sql`DATE_TRUNC('month', ${orders.createdAt})`)
        .orderBy(sql`month`)
    },
  },
},
roles: {
  analyst: ['view_orders', 'action_revenueReport'],
  admin: ['view_orders', 'edit_orders', 'action_revenueReport'],
  owner: ['view_orders', 'edit_orders', 'delete_orders', 'action_revenueReport'],
}
// Client
const report = await db.action('revenueReport', {
  startDate: '2025-01-01',
  endDate: '2025-12-31',
})
// report: { month: string; totalRevenue: number; orderCount: number; avgOrderValue: number }[]

Actions vs Permissions vs Middleware

Permissions (CRUD)MiddlewareActions
ScopeSingle tableSingle table (wraps CRUD)Any table, any logic
Client sendsSQL via Drizzle ProxySQL via Drizzle ProxyAction name + typed JSON
Defined onPermission objectPermission objectEngine config (top-level)
Access controlRole → permission → tableInherits from permissionRole includes action_* slug
Type safetySchema-generated table typesInherits from permissionZod input/output schemas
Use whenStandard reads and writesIntercept or extend a CRUD queryMulti-table logic, workflows, aggregations

Error Handling

Throw PermissionError to reject with 403 Forbidden:

myAction: {
  input: z.object({ id: z.string() }),
  run: async ({ user, db }, { id }) => {
    throw new PermissionError('Reason shown in error response')
  },
},

Invalid input (fails Zod validation) returns 400 Bad Request with the Zod error details. Any other thrown error returns 500 Internal Server Error and is logged but not exposed to the client. When using db.transaction(), any throw automatically rolls back all queries in that transaction.

On this page