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
- Client sends
POST /actions/{actionName}with a JSON body - Engine authenticates the user and resolves the session
- Engine checks if
action_{actionName}is in the user's role array - Engine validates the input against the Zod schema — rejects with
400if invalid - Engine calls
runwith{ user, db }and the validated input - Engine validates the output against the Zod schema (if defined)
- 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 JSONDefining Actions
Each action has three fields:
| Field | Type | Required | Description |
|---|---|---|---|
input | ZodSchema | Yes | Zod schema that validates and types the client's request body |
output | ZodSchema | No | Zod schema that validates and types the return value |
run | ({ user, db }, input) => Promise<Output> | Yes | The 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:
| Parameter | Type | Description |
|---|---|---|
user | UserSession | Resolved session (same as $user.* in filters) |
db | DrizzleInstance | Drizzle query builder — run any query, any table |
input | z.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: numberTeam 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) | Middleware | Actions | |
|---|---|---|---|
| Scope | Single table | Single table (wraps CRUD) | Any table, any logic |
| Client sends | SQL via Drizzle Proxy | SQL via Drizzle Proxy | Action name + typed JSON |
| Defined on | Permission object | Permission object | Engine config (top-level) |
| Access control | Role → permission → table | Inherits from permission | Role includes action_* slug |
| Type safety | Schema-generated table types | Inherits from permission | Zod input/output schemas |
| Use when | Standard reads and writes | Intercept or extend a CRUD query | Multi-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.