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.
Define an action
Add an actions array to your teenybase.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 DatabaseSettingsCall it:
curl -X POST http://localhost:8787/api/v1/action/increment_views \
-H 'Content-Type: application/json' \
-d '{"post_id": "abc123"}'Response:
[[{"views": 42}]]Parameters
Define typed parameters. They are validated before execution.
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.
{
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:
| Type | Required | Optional |
|---|---|---|
SELECT | where, selects, orderBy, limit, offset, groupBy, distinct | |
UPDATE | set | where, returning, or |
INSERT | values | returning, or |
DELETE | where, returning |
SELECT
{
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
{
name: 'increment_counter',
params: { id: 'string', amount: 'number' },
sql: {
type: 'UPDATE',
table: 'counters',
set: { value: sql`value + {:amount}` },
where: sql`id = {:id}`,
returning: ['*'],
},
}INSERT
{
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
{
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.
{
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:
[[{"credits": 95}], [{"credits": 105}]]Parameter substitution
Use {:paramName} inside sql tagged templates. This generates parameterized SQL, safe from injection.
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.
{
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:
const db = c.get('$db')
const results = await db.runAction('increment_views', { post_id: 'abc123' })For non-critical operations (view counting, logging):
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.