Functions

Define queries, mutations, and actions that run on Tether's infrastructure in a sandboxed Deno runtime — no server of your own required.

Function types

Tether supports four types of functions:

  • Query — Read-only database operations. Can be subscribed to for realtime updates.
  • Mutation — Write operations that modify data and trigger subscription updates.
  • Action — Business logic with side effects. Can call queries and mutations internally.
  • Cron — Scheduled functions that run automatically at specified intervals.

Defining queries

Create a file in tether/functions/:

// tether/functions/todos.ts
import { query, z } from '../_generated/db';

export const list = query({
  args: z.object({
    completed: z.boolean().optional(),
  }),
  handler: async ({ args, db }) => {
    return db.todos.findMany({
      where: args.completed !== undefined ? { completed: args.completed } : undefined,
      orderBy: { createdAt: 'desc' },
    });
  },
});

The handler receives a context object with args (validated arguments), db (database client), and ctx (auth context). The db object routes all operations to Tether's SQLite storage, whilst ctx.auth provides the calling user's identity.

Defining mutations

Mutations modify data and automatically invalidate related subscriptions:

// tether/functions/todos.ts
import { mutation, z } from '../_generated/db';

export const create = mutation({
  args: z.object({
    title: z.string().min(1),
  }),
  handler: async ({ args, db }) => {
    const todo = await db.todos.insert({
      title: args.title,
      completed: false,
    });
    return todo;
  },
});

export const toggle = mutation({
  args: z.object({
    _id: z.string(),
  }),
  handler: async ({ args, db }) => {
    const todo = await db.todos.findFirst({
      where: { _id: args._id },
    });
    if (!todo) throw new Error('Todo not found');

    await db.todos.update({
      where: { _id: args._id },
      data: { completed: !todo.completed },
    });
  },
});

Access control

Control who can call your functions using the access property. This is especially useful when exposing functions to SPAs via publishable keys.

Level Description
public Anyone with a valid publishable key can call this function. No authentication required.
authenticated Requires a valid JWT from your configured auth provider. This is the default.
internal Only callable with a secret API key. Publishable key requests are always rejected.
import { query, mutation, z } from '../_generated/db';

// Anyone can read posts (no JWT needed)
export const list = query({
  access: 'public',
  handler: async ({ db }) => {
    return db.posts.findMany({ orderBy: { createdAt: 'desc' } });
  },
});

// Only authenticated users can create posts
export const create = mutation({
  access: 'authenticated', // this is the default
  args: z.object({ title: z.string().min(1) }),
  handler: async ({ args, db, ctx }) => {
    return db.posts.insert({
      title: args.title,
      authorId: ctx.auth.userId,
    });
  },
});

// Admin-only: only callable via secret API key
export const deleteAll = mutation({
  access: 'internal',
  handler: async ({ db }) => {
    return db.posts.deleteAll();
  },
});

When no access is specified, functions default to authenticated. Server-side requests using a secret API key (tthr_dev_ / tthr_prod_) bypass access checks entirely.

OAuth2 scoped access

When a third-party application accesses your project via OAuth2, permissions are enforced at both the function level and the project/environment level. The OAuth2 token must have the correct permission (query, mutate, or action) for the specific project and environment being accessed.

Function-level access rules still apply on top of OAuth2 scoping — a function marked as internal cannot be called via OAuth2 tokens, and authenticated functions require the token to carry a valid user identity.

Defining actions

Actions contain business logic and can call queries and mutations internally. Use them for workflows that involve external APIs, emails, payments, or other side effects.

// tether/functions/orders.ts
import { action, z } from '../_generated/db';

export const checkout = action({
  args: z.object({
    orderId: z.string(),
  }),
  handler: async ({ args, tether }) => {
    const order = await tether.query('orders.get', { id: args.orderId });

    const payment = await fetch('https://api.stripe.com/v1/charges', {
      method: 'POST',
      headers: { Authorization: `Bearer ${tether.env.STRIPE_SECRET_KEY}` },
      body: new URLSearchParams({ amount: String(order.total), currency: 'gbp' }),
    }).then(r => r.json());

    await tether.mutation('orders.update', {
      id: args.orderId,
      status: 'paid',
      paymentId: payment.id,
    });

    return { success: true, paymentId: payment.id };
  },
});

Action handlers receive a context object with:

  • args — validated arguments from the request
  • db — database client (same as queries/mutations)
  • ctx — execution context with auth info
  • tether.query() — call other query functions
  • tether.mutation() — call other mutation functions
  • tether.env — access encrypted environment variables

Environment variables

Actions can access environment variables via the tether.env object. These are securely stored and encrypted in your project settings on the Tether dashboard.

import { action, z } from '../_generated/db';

export const checkout = action({
  args: z.object({ orderId: z.string() }),
  handler: async ({ args, tether }) => {
    const stripeKey = tether.env.STRIPE_SECRET_KEY;
    const sendgridKey = tether.env.SENDGRID_API_KEY;
    // Use them in your API calls
  },
});

Setting environment variables

Configure environment variables in your project's Settings tab on the dashboard. Values are encrypted at rest and can only be replaced, not viewed — this ensures your secrets stay secure even if someone gains access to your dashboard.

For local development, you can use a .env file in your project root. When tthr dev deploys your functions to the development environment, these variables are included automatically.

Cron functions

Schedule functions to run automatically at specified intervals using cron expressions. Cron jobs are configured in the dashboard and trigger your functions via WebSocket.

// tether/functions/reports.ts
import { mutation, z } from '../_generated/db';

export const generateDaily = mutation({
  args: z.object({
    date: z.string().optional(),
  }),
  handler: async ({ args, db, log }) => {
    const targetDate = args.date || new Date().toISOString().split('T')[0];

    log.info('Generating daily report', { date: targetDate });

    const stats = await db.orders.count({
      where: { createdAt: { gte: targetDate } },
    });

    await db.reports.insert({
      type: 'daily',
      date: targetDate,
      data: JSON.stringify({ orderCount: stats }),
    });

    log.info('Report generated successfully');
    return { success: true, orderCount: stats };
  },
});

Setting up cron jobs

Configure cron schedules in your project's Cron tab on the dashboard:

  • Function name: The exported function to call (e.g. reports.generateDaily)
  • Schedule: Standard cron expression (e.g. 0 9 * * * for 9am daily)
  • Timezone: The timezone for schedule evaluation
  • Arguments: Optional JSON arguments to pass to the function

Cron functions execute on Tether's Deno runtime alongside your other functions. They have full access to the same handler context (db, ctx, log) and are deployed automatically when you run tthr deploy.

Handler context

Every function handler receives a context object with the following properties:

Property Description
db Database client with typed access to all tables
args Validated arguments (only when args schema is defined)
ctx Execution context with authentication info
log Logger for sending structured logs to the dashboard

Authentication context

Access the current user's identity via ctx.auth:

export const getProfile = query({
  handler: async ({ ctx, db }) => {
    if (!ctx.auth.userId) {
      throw new Error('Not authenticated');
    }
    return db.users.findById(ctx.auth.userId);
  },
});

Logging

Use the log object to send structured logs to the Tether dashboard:

export const processOrder = mutation({
  args: z.object({ orderId: z.string() }),
  handler: async ({ args, db, log }) => {
    log.info('Processing order', { orderId: args.orderId });

    try {
      const order = await db.orders.findById(args.orderId);
      log.debug('Order loaded', { total: order.total });

      log.info('Order processed successfully');
      return { success: true };
    } catch (error) {
      log.error('Failed to process order', { error: error.message });
      throw error;
    }
  },
});

Available log levels: log.log(), log.info(), log.warn(), log.error(), log.debug()

Shared helpers

You can extract shared logic into helper files prefixed with _ (e.g. _helpers.ts). These files are not deployed as standalone functions — they're bundled into any function file that imports from them.

tether/functions/
  _helpers.ts          ← shared utilities (not deployed directly)
  channel.ts           ← imports from _helpers.ts
  notifications.ts     ← imports from _helpers.ts
// tether/functions/_helpers.ts
export function formatChannel(channel: any) {
  return { ...channel, displayName: `#${channel.name}` };
}

// tether/functions/channel.ts
import { query } from "@tthr/server";
import { formatChannel } from "./_helpers";

export const getChannels = query({
  handler: async ({ db }) => {
    const channels = await db.channels.findMany();
    return channels.map(formatChannel);
  },
});

Helper code that's referenced by a function is shown in the dashboard as expandable artefacts beneath the function source.

Argument validation

Function arguments are validated using Zod schemas. Invalid arguments return a 400 error with validation details.

import { mutation, z } from '../_generated/db';

export const createUser = mutation({
  args: z.object({
    email: z.string().email(),
    name: z.string().min(2).max(100),
    age: z.number().int().min(0).optional(),
    role: z.enum(['user', 'admin', 'moderator']).default('user'),
    preferences: z.object({
      newsletter: z.boolean().default(false),
      theme: z.enum(['light', 'dark']).optional(),
    }).optional(),
  }),
  handler: async ({ args, db }) => {
    return db.users.insert(args);
  },
});

Common Zod validators: .min(), .max(), .email(), .url(), .uuid(), .regex(), .optional(), .default(), .nullable()

Execution model

All Tether functions execute on Tether's infrastructure via a Deno runtime sidecar. You do not need to run your own server — Tether handles function execution, database access, and realtime subscription invalidation entirely.

When you run tthr deploy, your functions are bundled and pushed to Tether's servers, where they run in a sandboxed Deno environment with direct access to your project's SQLite database.

Development workflow

During development, tthr dev watches your tether/functions/ directory and automatically deploys changes to the development environment on each file save. This gives you a live feedback loop without needing to manually redeploy.

Generic CRUD vs custom functions

There are two ways to interact with your database:

  • Generic CRUD operations — Use table.operation patterns like posts.list, posts.get, posts.create directly via the Tether client. These are available when connecting with a secret API key.
  • Custom functions — Write business logic in tether/functions/ and call them via the SDK. These are deployed to Tether's Deno runtime and are the only way to interact with your database from SPA clients using a publishable key.

SPA and publishable key access

Single-page applications that connect using a publishable key can only call deployed functions — they cannot perform raw CRUD operations. This ensures that all data access from client-side applications goes through your defined functions, where you control validation, access rules, and business logic.