Actions

Actions are server-side logic defined in teenybase.ts and callable via POST /api/v1/action/{name}. Use them for anything CRUD endpoints can't do: counters, transactions, aggregations, bulk operations.

Using a coding agent?

Define an action

Add an actions array to your teenybase.ts:

ts
import { DatabaseSettings, sql, sqlValue } from 'teenybase'

export default {
  // ...tables, jwtSecret, etc.
  actions: [
    {
      name: 'increment_views',
      params: { post_id: 'string' },
      sql: {
        type: 'UPDATE',
        table: 'posts',
        set: { views: sql`views + 1` },
        where: sql`id = {:post_id}`,
        returning: ['views'],
      },
    },
  ],
} satisfies DatabaseSettings

Call it:

bash
curl -X POST http://localhost:8787/api/v1/action/increment_views \
  -H 'Content-Type: application/json' \
  -d '{"post_id": "abc123"}'

Response:

json
[[{"views": 42}]]

Parameters

Define typed parameters. They are validated before execution.

ts
params: {
  email: 'string',
  amount: 'number',
  active: 'boolean',
  page: { type: 'integer', optional: true, default: 1 },
}

Types: string, number, integer, boolean. Extra parameters not in the definition are rejected.

Access control

Two layers: requireAuth and guard.

ts
{
  name: 'admin_action',
  requireAuth: true,                   // 401 if not authenticated
  guard: "auth.role == 'admin'",       // 403 if not admin
  params: { user_id: 'string' },
  sql: { /* ... */ },
}

Order: requireAuth > guard > parameter validation > execution.

SQL mode

Raw query objects. Bypasses table rules entirely.

Every query needs 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

SELECT

ts
{
  name: 'active_users',
  params: { role: 'string' },
  sql: {
    type: 'SELECT',
    table: 'users',
    where: sql`role = {:role} AND email_verified = true`,
    orderBy: 'created DESC',
    limit: 50,
  },
}

UPDATE

ts
{
  name: 'increment_counter',
  params: { id: 'string', amount: 'number' },
  sql: {
    type: 'UPDATE',
    table: 'counters',
    set: { value: sql`value + {:amount}` },
    where: sql`id = {:id}`,
    returning: ['*'],
  },
}

INSERT

ts
{
  name: 'log_event',
  params: { user_id: 'string', action: 'string' },
  sql: {
    type: 'INSERT',
    table: 'audit_log',
    values: {
      user_id: sql`{:user_id}`,
      action: sql`{:action}`,
      timestamp: sql`CURRENT_TIMESTAMP`,
    },
    returning: ['id'],
  },
}

DELETE

ts
{
  name: 'cleanup_expired',
  requireAuth: true,
  guard: "auth.role == 'admin'",
  sql: {
    type: 'DELETE',
    table: 'sessions',
    where: sql`created < datetime('now', '-30 days')`,
    returning: ['id'],
  },
}

Multiple queries (transaction)

Pass an array. All queries execute in a single batch.

ts
{
  name: 'transfer_credits',
  requireAuth: true,
  params: { from_id: 'string', to_id: 'string', amount: 'integer' },
  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}`,
    },
  ],
}

Response for multiple queries is an array of arrays, one per query:

json
[[{"credits": 95}], [{"credits": 105}]]

Parameter substitution

Use {:paramName} inside sql tagged templates. This generates parameterized SQL, safe from injection.

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

Steps mode

Steps mode is the alternative to SQL mode. Instead of writing raw SQL, you write expressions using the same language as RLS rules. Steps go through the table layer, which means your table's RLS rules can be applied to them (controlled by applyTableRules, default true).

Use steps mode when you want actions that respect your existing access rules. Use SQL mode when you need full SQL control and want to handle access via guard instead.

ts
{
  name: 'archive_post',
  requireAuth: true,
  params: { post_id: 'string' },
  steps: {
    type: 'UPDATE',
    table: 'posts',
    setValues: { status: 'archived' },
    where: "id = params.post_id & author_id = auth.uid",
  },
}

Steps use the same expression language as RLS rules: auth.uid, auth.role, params.*, column names, and operators like ==, !=, &, |.

Set applyTableRules: false to bypass RLS (default is true). Use guard for access control when bypassing rules.

Calling from worker code

Use db.runAction() in SSR routes or custom middleware without an HTTP round-trip:

ts
const db = c.get('$db')
const results = await db.runAction('increment_views', { post_id: 'abc123' })

For non-critical operations (view counting, logging):

ts
c.executionCtx.waitUntil(
  db.runAction('increment_views', { post_id: slug }).catch(() => {})
)

Full reference

For complete query object properties, conflict strategies, sensitive field filtering, and error responses, see the Actions Reference.