ValetAlpha

Actions

Server-only functions for external I/O, transactional work, and side effects.

Actions are Valet's server-only functions. Use them when the work is not a local-first mutation:

  • Calling an external HTTP API with fetch
  • Sending email
  • Running privileged server logic that should never replay offline
  • Doing multi-step business logic that still needs ctx.db

If the work should replay locally and sync later, use a mutation. If the work must only happen on the server, use an action.

Define an action

// valet/users.ts
import { defineAction, v } from './_generated/valet/api'

export const sendWelcomeEmail = defineAction({
  args: { userId: v.id('users') },
  access: 'authenticated',
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId)
    if (!user) throw new Error('User not found')

    await ctx.email.enqueue({
      to: user.email,
      subject: 'Welcome to Valet',
      html: `<h1>Hello ${user.name ?? 'there'}</h1>`,
    })

    return { accepted: true }
  },
})

defineAction looks like defineMutation, but it has a different execution model:

  • It is always invoked over HTTP
  • It never runs optimistically on the client
  • It can use fetch
  • It can enqueue email and push work
  • It can run as a user, anonymously, or as a system caller

Call an action from React

import { api } from './_generated/valet/api'
import { useAction } from './_generated/valet/react'

function InviteButton({ userId }: { userId: string }) {
  const sendWelcomeEmail = useAction(api.actions.sendWelcomeEmail)

  return (
    <button
      onClick={() => sendWelcomeEmail({ userId })}
    >
      Send welcome email
    </button>
  )
}

You can also supply your own idempotency key:

await sendWelcomeEmail(
  { userId },
  { idempotencyKey: `welcome-${userId}` },
)

That is useful when you want retries after a reload to dedupe against the same logical user action.

If you already have the generated provider loaded, action refs also expose a direct helper:

import { api } from './_generated/valet/api'

await api.actions.sendWelcomeEmail.invoke(
  { userId },
  { idempotencyKey: `welcome-${userId}` },
)

Access control

Actions declare who may call them:

export const searchDocs = defineAction({
  args: { q: v.string() },
  access: 'public',
  handler: async (_ctx, args) => ({ q: args.q }),
})

Available values:

  • 'authenticated' (default): requires a bearer token
  • 'public': allows anonymous callers
  • 'system': only internal callers such as cron or admin triggers

Use fetch

Actions can call external HTTP services:

export const enrichProfile = defineAction({
  args: { github: v.string(), userId: v.id('users') },
  access: 'authenticated',
  handler: async (ctx, args) => {
    const response = await fetch(`https://api.github.com/users/${args.github}`, {
      headers: {
        Accept: 'application/json',
        'User-Agent': 'valet-app',
      },
    })

    if (!response.ok) {
      throw new Error(`GitHub returned ${response.status}`)
    }

    const profile = await response.json()

    await ctx.db.patch(args.userId, {
      bio: profile.bio ?? null,
      avatarUrl: profile.avatar_url ?? null,
    })

    return { updated: true }
  },
})

Important fetch rules:

  • fetch is available in actions and cron-dispatched actions only
  • fetch is blocked in queries and mutations
  • fetch to private or internal addresses is rejected before the request is made
  • In development mode (VALET_DEV=1), localhost fetches are allowed so local integrations are practical

Blocked destinations include localhost in production, RFC 1918 private ranges, link-local ranges, cloud metadata addresses, and *.internal.

Transactions

Actions use auto-commit database calls by default. If several writes must succeed or fail together, wrap them in ctx.db.transaction(...):

export const chargeOrder = defineAction({
  args: { orderId: v.id('orders') },
  access: 'authenticated',
  handler: async (ctx, args) => {
    const gatewayResponse = await fetch('https://payments.example.com/charge', {
      method: 'POST',
      body: JSON.stringify({ orderId: args.orderId }),
    })

    if (!gatewayResponse.ok) {
      throw new Error('Payment failed')
    }

    await ctx.db.transaction(async (tx) => {
      const order = await tx.get(args.orderId)
      if (!order) throw new Error('Order not found')

      await tx.patch(order._id, { status: 'paid' })
      await ctx.email.enqueue({
        to: order.email,
        subject: 'Payment received',
        text: 'Your order is confirmed.',
      })
    })

    return { ok: true }
  },
})

Transaction rules:

  • fetch inside ctx.db.transaction(...) throws immediately
  • ctx.email.enqueue(...) is allowed inside a transaction
  • Nested transactions are not supported
  • If the transaction rolls back, any email enqueue inside it rolls back too

The right pattern is: fetch first, then open the transaction for the DB work and outbox writes.

Retry-safe actions

Use safeToRetry: true only when the whole handler is safe to rerun after an ambiguous failure or timeout:

export const ensureReceipt = defineAction({
  args: { orderId: v.id('orders') },
  safeToRetry: true,
  access: 'authenticated',
  handler: async (ctx, args) => {
    await ctx.db.insertOrIgnore('receipts', {
      _id: `receipt-${args.orderId}`,
      orderId: args.orderId,
      sent: 1,
    })

    await ctx.email.enqueue({
      to: 'user@example.com',
      subject: 'Receipt',
      html: '<p>Thanks</p>',
      deliveryKey: `receipt-${args.orderId}`,
    })

    return { ok: true }
  },
})

Guidelines:

  • safeToRetry covers both failed and timeout, not just timeouts
  • Use ctx.db.insertOrIgnore(...) for idempotent insert-once rows
  • Give every outbox enqueue a stable deliveryKey
  • If you cannot make every side effect repeat-safe, leave safeToRetry off and handle retries explicitly

ctx.invoker

Actions always know who invoked them:

if (ctx.invoker.type === 'user') {
  console.log(ctx.invoker.userId)
}

if (ctx.invoker.type === 'anonymous') {
  console.log('Guest action call')
}

if (ctx.invoker.type === 'system') {
  console.log(ctx.invoker.source)
}

System invokers are used by cron jobs and admin triggers. Cron-dispatched actions also receive timing metadata such as scheduledAt, dispatchedAt, and catch-up fields when applicable.

Sensitive actions

If an action returns sensitive data that should not be stored for idempotent replays, mark it as sensitive:

export const rotateSecret = defineAction({
  args: {},
  sensitive: true,
  handler: async () => {
    return { secretIssued: true }
  },
})

Sensitive action results are returned to the original caller, but Valet does not retain the args/result body for later duplicate responses.

Debugging action failures

Useful places to look when an action is not behaving the way you expect:

  • The HTTP response from useAction(...) or client.invokeAction(...)
  • _valet_action_invocations in the project database
  • /admin/outbox for queued or dead-lettered email work
  • /admin/cron/runs for cron-dispatched actions

On this page