Actions Guide

Actions let you run server-side SQL or multi-step logic through a single API call. You define them in your teenybase.ts config, and they become available at POST /api/v1/action/{name}.

You choose one of two modes per action:

  • SQL mode -- raw query objects with parameterized SQL. Full control, bypasses table rules.
  • Steps mode -- expression-based statements that go through the table layer. Can apply RLS rules.

Quick Example

typescript
// teenybase.ts
import { DatabaseSettings, sql, sqlValue } from 'teenybase'

export default {
    // ... tables, jwtSecret, etc.
    actions: [
        {
            name: 'increment_counter',
            params: { amount: 'number' },
            requireAuth: true,
            sql: {
                type: 'UPDATE',
                table: 'counters',
                set: { value: 'value + {:amount}' },
                where: sql`id = 1`,
                returning: ['*'],
            },
        },
    ],
} satisfies DatabaseSettings

Call it:

bash
curl -X POST http://localhost:8787/api/v1/action/increment_counter \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -d '{ "amount": 5 }'

Response:

json
[[{ "id": 1, "value": 15 }]]

SQL Mode

SQL mode gives you raw query objects with full control over the SQL. Queries bypass table rules entirely -- use guard or requireAuth for access control.

Every query requires type and table. The other properties depend on the type:

TypeRequiredOptional
SELECTwhere, selects, orderBy, limit, offset, groupBy, distinct
UPDATEsetwhere, returning, or
INSERTvaluesreturning, or
DELETEwhere, returning

Query property reference

PropertyUsed inTypeDescription
whereSELECT, UPDATE, DELETEsql`...`Filter condition. Optional for all types in SQL mode.
setUPDATERecord<string, sql|string>Fields to update. Values can be sql tagged templates or strings. Required for UPDATE.
valuesINSERTRecord<string, sql|string>Fields to insert. Values can be sql tagged templates or strings. Required for INSERT.
selectsSELECT(string | {q, as})[]Fields to return. Strings for column names, {q: 'COUNT(*)', as: 'count'} for expressions. Default: all fields.
returningUPDATE, INSERT, DELETE(string | {q, as})[]Fields to return after mutation. ['*'] for all fields. Sensitive fields (noSelect: true) are excluded for non-admin users.
orderBySELECTstring | string[]Sort order. e.g. 'created DESC'.
limitSELECTnumberMax rows to return.
offsetSELECTnumberRows to skip (for pagination).
groupBySELECTstring[]Group results by fields or expressions. e.g. ["strftime('%Y-%m', created)"].
distinctSELECTbooleanDeduplicate results.
orUPDATE, INSERTstringConflict strategy: ABORT, FAIL, IGNORE, REPLACE, ROLLBACK.

Steps mode uses different property names: select (not selects), order (not orderBy), group (not groupBy), and setValues (not set).

Single Query

typescript
{
    name: 'get_active_users',
    params: { role: 'string' },
    sql: {
        type: 'SELECT',
        table: 'users',
        where: sql`role = {:role} AND email_verified = true`,
    },
}

Multiple Queries (Transaction)

Pass an array -- all queries execute in a single D1 batch (transaction).

typescript
{
    name: 'transfer_credits',
    params: { from_id: 'string', to_id: 'string', amount: 'integer' },
    requireAuth: true,
    sql: [
        {
            type: 'UPDATE',
            table: 'accounts',
            set: { credits: 'credits - {:amount}' },
            where: sql`id = {:from_id} AND credits >= {:amount}`,
        },
        {
            type: 'UPDATE',
            table: 'accounts',
            set: { credits: 'credits + {:amount}' },
            where: sql`id = {:to_id}`,
        },
    ],
}

Query Object Properties

Each query object has a type that determines the available properties:

SELECT

typescript
{
    type: 'SELECT',
    table: 'posts',
    where: sql`published = true AND category = {:category}`,
    // Optional:
    selects: ['id', 'title', 'created'],  // Fields to return (default: all)
    limit: 10,
    offset: 0,
    orderBy: 'created DESC',
    distinct: true,
    groupBy: ['category'],
}

UPDATE

typescript
{
    type: 'UPDATE',
    table: 'posts',
    set: { view_count: sql`view_count + 1` },       // SQL expressions (values must be sql`` or sqlValue())
    where: sql`id = {:post_id}`,
    returning: ['*'],                               // Return updated rows
    // Optional:
    or: 'IGNORE',                                   // Conflict strategy
}

INSERT

typescript
{
    type: 'INSERT',
    table: 'audit_log',
    values: { user_id: sql`{:user_id}`, action: sql`{:action}`, timestamp: sql`CURRENT_TIMESTAMP` },
    returning: ['id'],
    // Optional:
    or: 'IGNORE',                                   // Conflict strategy: ABORT|FAIL|IGNORE|REPLACE|ROLLBACK
}

DELETE

typescript
{
    type: 'DELETE',
    table: 'sessions',
    where: sql`user_id = {:user_id} AND expired = true`,
    returning: ['id'],
}

Parameter Substitution

Use {:paramName} syntax inside sql tagged templates. Parameters from the request body are merged into the query automatically.

typescript
where: sql`email = {:email} AND id = {:id}`

This generates parameterized SQL (email = ? AND id = ?) -- safe from SQL injection.

In set expressions (for UPDATE), parameters are also available:

typescript
set: { credits: 'credits + {:amount}' }

Steps Mode

Steps mode uses expression-based statements that go through the table layer. This means:

  • Table rules (RLS) can apply (controlled by applyTableRules)
  • Expressions use the same syntax as rule expressions
  • Auth context (auth.*) and params (params.*) are available in expressions

Single Step

typescript
{
    name: 'mark_verified',
    requireAuth: true,
    params: { email: 'string' },
    steps: {
        type: 'UPDATE',
        table: 'users',
        setValues: { email_verified: true },
        where: "email = params.email & email_verified = false & id = auth.uid",
    },
}

Multiple Steps

Pass an array -- steps execute sequentially in a single transaction.

typescript
{
    name: 'archive_and_notify',
    requireAuth: true,
    params: { post_id: 'string' },
    steps: [
        {
            type: 'UPDATE',
            table: 'posts',
            setValues: { status: 'archived' },
            where: "id = params.post_id & author_id = auth.uid",
        },
        {
            type: 'INSERT',
            table: 'notifications',
            expr: { user_id: 'auth.uid', message: "'Post archived'" },
        },
    ],
}

Expression Syntax in Steps

Steps use the same expression language as table rules:

VariableDescription
auth.uidAuthenticated user's ID
auth.*Any field from the auth user's record (e.g., auth.role)
params.*Action parameters (e.g., params.email)
Column namesColumns from the target table

Operators: ==, !=, >, <, >=, <=, ~ (LIKE), & (AND), | (OR)

applyTableRules

Controls whether table-level RLS rules apply to steps:

typescript
{
    name: 'admin_reset',
    applyTableRules: false,       // Bypass table rules (default: true)
    guard: "auth.role == 'admin'",  // Use guard for access control instead
    steps: { /* ... */ },
}
ValueBehavior
true (default)Table rules from the rules extension apply. Steps respect RLS.
falseRules are skipped. The action has unrestricted access. Use guard or requireAuth for access control.

Parameters

Define typed parameters that are validated before the action executes.

Shorthand

typescript
params: {
    email: 'string',
    amount: 'number',
    active: 'boolean',
    page: 'integer',
}

Full Definition

typescript
params: {
    email: { type: 'string', description: 'User email address' },
    page: { type: 'integer', optional: true, default: 1 },
    limit: { type: 'integer', optional: true, default: 10, description: 'Max results' },
}

Parameter Types

TypeDescriptionExample
'string'Any text value"alice@example.com"
'number'Floating-point or integer3.14
'integer'Integer only42
'boolean'true or falsetrue

Param Options

PropertyTypeDefaultDescription
typestring--Parameter type (required).
optionalbooleanfalseAllow the parameter to be omitted.
defaultany--Default value when omitted. Only used if optional.
descriptionstring--Description for OpenAPI docs. Max 500 characters.

Validation

Parameters are validated using Zod schemas built from your definition. Strict mode is enabled -- extra parameters not defined in params are rejected.

Error response (400):

json
{
    "code": 400,
    "message": "Validation Error",
    "data": {
        "email": { "_errors": ["Required"] },
        "amount": { "_errors": ["Expected number, received string"] }
    }
}

Access Control

Actions have two layers of access control: requireAuth and guard.

requireAuth

The simplest check -- does the request have a valid JWT token?

typescript
{
    name: 'my_action',
    requireAuth: true,        // 401 if not authenticated
    // ...
}

Checked before the guard and before any SQL execution.

Error response (401):

json
{ "code": 401, "message": "Authentication required" }

Guard Expressions

Guards are expressions evaluated before the action executes. If the expression evaluates to false, the request is rejected with 403.

typescript
{
    name: 'admin_action',
    guard: "auth.uid != null",    // Must be logged in
    // ...
}

{
    name: 'admin_only',
    guard: "auth.role == 'admin'",  // Must be admin
    // ...
}

Available variables in guards:

  • auth.* -- authenticated user context (uid, email, role, etc.)
  • params.* -- action parameters
  • SQLite functions like unixepoch() for time-based guards

How guards work internally:

  1. If the expression fully resolves to a literal (e.g., auth.uid != null where auth.uid is known), it's evaluated immediately in JavaScript.
  2. If it uses SQLite functions, it's wrapped as a SQL statement and executed in the same transaction as the action queries. If it fails, the entire transaction is rolled back.

Error response (403):

json
{ "code": 403, "message": "Forbidden" }

Combining Both

typescript
{
    name: 'sensitive_action',
    requireAuth: true,                    // First: must be authenticated
    guard: "auth.role == 'admin'",        // Then: must be admin
    params: { target_id: 'string' },
    sql: { /* ... */ },
}

Evaluation order: requireAuth -> guard -> parameter validation -> SQL execution.

Sensitive Field Filtering

When you use returning: ['*'] in SQL mode, sensitive fields (those with noSelect: true in the field definition) are automatically excluded from the response for non-admin users.

typescript
// Field definition
{ name: 'password', type: 'text', sqlType: 'text', noSelect: true }

// Action with returning: ['*']
{
    name: 'update_user',
    sql: {
        type: 'UPDATE',
        table: 'users',
        set: { email_verified: sqlValue(true) },
        where: sql`id = {:id}`,
        returning: ['*'],              // password will be excluded
    },
}

The response includes all fields except password, password_salt, and any other noSelect fields. Superadmin users bypass this filtering and see all fields.

API Reference

Endpoint

POST /api/v1/action/{name}

Request

Headers:

  • Content-Type: application/json
  • Authorization: Bearer {token} (if requireAuth: true or guard uses auth context)

Body: JSON object with parameter values matching the action's params definition.

json
{ "email": "alice@example.com", "amount": 5 }

Response

Success (200): Array of arrays -- each element corresponds to one query's results.

json
// Single query
[[{ "id": "abc123", "email_verified": true }]]

// Multiple queries
[
    [{ "id": "abc123", "credits": 95 }],
    [{ "id": "def456", "credits": 105 }]
]

Errors:

StatusConditionBody
400Invalid parameters or request body{ "code": 400, "message": "...", "data": {...} }
401requireAuth: true but no valid token{ "code": 401, "message": "Authentication required" }
403Guard expression failed{ "code": 403, "message": "Forbidden" }
404Action name not found{ "code": 404, "message": "Action not found - {name}" }
500SQL execution error or misconfiguration{ "code": 500, "message": "..." }

Calling from Worker Code

Use db.runAction() to call actions from your worker code (SSR routes, custom middleware, etc.) without an HTTP round-trip:

typescript
// In an SSR route handler or custom middleware
const db = c.get('$db')
const results = await db.runAction('increment_counter', { amount: 1 })

// Fire-and-forget (non-critical operations like view counting)
c.executionCtx.waitUntil(
    db.runAction('increment_view', { slug }).catch(() => {})
)

runAction executes the action directly -- same validation, guards, and SQL execution as the HTTP endpoint.

Examples

Read-only query with params

Fetch posts by category with pagination.

typescript
{
    name: 'posts_by_category',
    params: {
        category: 'string',
        page: { type: 'integer', optional: true, default: 1 },
        limit: { type: 'integer', optional: true, default: 20 },
    },
    sql: {
        type: 'SELECT',
        table: 'posts',
        where: sql`category = {:category} AND published = true`,
        orderBy: 'created DESC',
        limit: 20,     // Note: dynamic limit from params requires steps mode
        offset: 0,
    },
}
bash
curl -X POST http://localhost:8787/api/v1/action/posts_by_category \
  -H 'Content-Type: application/json' \
  -d '{ "category": "tech" }'

Mutation with auth check

Mark a user's email as verified (admin-only).

typescript
{
    name: 'admin_verify_email',
    requireAuth: true,
    guard: "auth.role == 'admin'",
    params: { user_id: 'string' },
    sql: {
        type: 'UPDATE',
        table: 'users',
        set: { email_verified: sqlValue(true) },
        where: sql`id = {:user_id} AND email_verified = false`,
        returning: ['id', 'email', 'email_verified'],
    },
}

Multi-step workflow

Soft-delete a post and log the action.

typescript
{
    name: 'soft_delete_post',
    requireAuth: true,
    params: { post_id: 'string' },
    applyTableRules: false,
    guard: "auth.uid != null",
    steps: [
        {
            type: 'UPDATE',
            table: 'posts',
            setValues: { deleted: true },
            where: "id = params.post_id & author_id = auth.uid",
        },
        {
            type: 'INSERT',
            table: 'audit_log',
            expr: {
                user_id: 'auth.uid',
                action: "'delete_post'",
                target_id: 'params.post_id',
            },
        },
    ],
}

Aggregation / reporting query

Get user registration stats.

typescript
{
    name: 'registration_stats',
    requireAuth: true,
    guard: "auth.role == 'admin'",
    sql: {
        type: 'SELECT',
        table: 'users',
        selects: [
            { q: "strftime('%Y-%m', created)", as: 'month' },
            { q: 'COUNT(*)', as: 'count' },
        ],
        groupBy: ["strftime('%Y-%m', created)"],
        orderBy: 'month DESC',
        limit: 12,
    },
}
json
[[
    { "month": "2025-03", "count": 142 },
    { "month": "2025-02", "count": 98 },
    { "month": "2025-01", "count": 67 }
]]